From 3b5c5481c70529841a54783630cbb0c5059f99eb Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Wed, 18 Feb 2026 11:51:02 +0200 Subject: [PATCH 1/8] event triggers refactor - more generic eventTriggers, allow adding triggers more easily --- cleanCode.md | 173 ++++++++++++++ packages/interact/src/handlers/click.ts | 190 --------------- .../interact/src/handlers/effectHandlers.ts | 142 ++++++++++++ .../interact/src/handlers/eventTrigger.ts | 200 ++++++++++++++++ packages/interact/src/handlers/hover.ts | 216 ------------------ packages/interact/src/handlers/index.ts | 70 ++++-- packages/interact/src/types.ts | 16 ++ packages/interact/test/mini.spec.ts | 1 + packages/interact/test/web.spec.ts | 1 + 9 files changed, 582 insertions(+), 427 deletions(-) create mode 100644 cleanCode.md delete mode 100644 packages/interact/src/handlers/click.ts create mode 100644 packages/interact/src/handlers/effectHandlers.ts create mode 100644 packages/interact/src/handlers/eventTrigger.ts delete mode 100644 packages/interact/src/handlers/hover.ts diff --git a/cleanCode.md b/cleanCode.md new file mode 100644 index 00000000..d60e98e2 --- /dev/null +++ b/cleanCode.md @@ -0,0 +1,173 @@ +# Clean Code Rules for LLM (JavaScript) + +Behavioral constraints for LLMs modifying or generating JavaScript code. +Ordered by priority — higher rules take precedence when rules conflict. + +--- + +## 1. Minimal Change Principle ⚡ Highest Priority + +* **Do not touch working code** unless strictly required for the requested behavior. +* **Never refactor, rename, reorder, or restructure** unrelated code. +* Prefer the **smallest possible diff**. +* Assume all existing code is **intentional and correct**. +* **Match the existing structure.** Use the same control flow, naming, patterns, and conventions as the code you're changing or the nearest similar file. Do not introduce new abstractions (wrapper factories, generic helpers) when the existing code uses direct approaches. +* **Preserve intent over style.** Do not rewrite code solely to match these rules. These rules must never override domain meaning or deliberate design choices. + +> Change only what is directly linked to the requested behavior — nothing more. When in doubt, keep the same structure as the original. + +--- + +## 2. Clarity Over Performance + +* Prefer **simple, explicit, readable code** over micro-optimizations. +* Avoid clever tricks, compressed expressions, or obscure patterns. +* Optimize **only when explicitly requested** or clearly critical. +* Prefer direct boolean forms: `x === 'toggle'` over `x !== 'toggle' ? false : true`. + +--- + +## 3. Control Flow: Flat and Explicit + +* Prefer **flattened control flow** over nested `if` trees. +* Unite `if` statements that lead to the same behavior. +* Use **early guard returns** to simplify logic. +* If flattening hurts readability, extract **well-named helpers**. +* Prefer **a single final return** per function; multiple early returns are fine for guards and impossible states. + +### Example + +❌ Bad + +```js +if (a) { + if (b) { + doX(); + } else if (c) { + doY(); + } +} +``` + +✅ Better + +```js +if (!a) return; +if (b) doX(); +if (!b && c) doY(); +``` + +--- + +## 4. Short, Focused Functions + +* Functions should fit on **one screen** (~100 lines max). +* If a function does more than one thing, mixes abstraction levels, or needs explanation → extract helpers. + +> If you need comments to explain *what* a function does, it's too big. + +--- + +## 5. Separation of Concerns + +* High-level logic must not contain low-level details. +* Extract validation, parsing, formatting, and calculations into helpers. +* **Helpers are a feature, not a smell.** Prefer named helpers over inline complexity. Names should explain **why**, not **how**. + +### Example + +❌ Bad + +```js +if (user && user.age > 18 && user.status === 'active') { ... } +``` + +✅ Better + +```js +if (isEligibleUser(user)) { ... } +``` + +> High-level code should read like a story. + +--- + +## 6. Don't Repeat Yourself (DRY) + +* Never duplicate logic, conditions, or transformations. +* Extract and reuse — even if the helper feels trivial. + +--- + +## 7. Immutability First + +* Always prefer `const` over `let`. +* Avoid mutation unless unavoidable; return new values instead. + +--- + +## 8. Limit Function Arguments + +* Avoid more than **4 arguments**. Group related arguments into objects or extract helpers. + +> Many arguments signal unclear boundaries or mixed concerns. + +--- + +## 9. Comments: Only When Necessary + +* Add comments only when **strictly necessary** (non-obvious workarounds, critical "why" that code/naming cannot express). +* If a comment restates the code, remove it. If it explains *what*, improve the code instead. + +--- + +## 10. Prefer Loose Data Shapes Over Discriminated Unions + +* Prefer a **single object with optional properties** over discriminated unions when the code only checks "which keys are present." +* Don't add structure (e.g. a `kind` field) that exists only for the type system. + +### Example + +❌ Discriminated union + +```js +type NotifyConfig = + | { kind: 'scheduled'; at: Date } + | { kind: 'delay'; ms: number } + | { kind: 'immediate' }; +``` + +✅ Optional properties + +```js +type NotifyConfig = { at?: Date; ms?: number; immediate?: true }; +``` + +--- + +## 11. Avoid Unnecessary Parameterization + +* Don't add a parameter when the only value ever passed is created in the same scope. Close over it instead. + +--- + +## 12. Declare Variables Near Usage + +* Declare variables **close to where they're first used**, not at the top of a block. + +--- + +## Self-Check Before Responding + +Before producing code, verify: + +1. Did I make the smallest possible change? +2. Did I preserve the original structure and intent? +3. Is the code clear and readable? +4. Is control flow as flat as possible? +5. Did I avoid unnecessary mutation, arguments, or parameters? +6. Are comments limited to what's strictly necessary? +7. Could optional properties replace a discriminated union here? +8. Are variables declared near their usage? + +If **any answer is "no"**, revise. diff --git a/packages/interact/src/handlers/click.ts b/packages/interact/src/handlers/click.ts deleted file mode 100644 index 40c0dfb1..00000000 --- a/packages/interact/src/handlers/click.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { getAnimation } from '@wix/motion'; -import type { AnimationGroup } from '@wix/motion'; -import type { - TimeEffect, - TransitionEffect, - StateParams, - HandlerObjectMap, - PointerTriggerParams, - EffectBase, - IInteractionController, - InteractOptions, -} from '../types'; -import { - effectToAnimationOptions, - addHandlerToMap, - removeElementFromHandlerMap, -} from './utilities'; -import fastdom from 'fastdom'; - -const handlerMap = new WeakMap() as HandlerObjectMap; - -function createTimeEffectHandler( - element: HTMLElement, - effect: TimeEffect & EffectBase, - options: PointerTriggerParams, - reducedMotion: boolean = false, - selectorCondition?: string, -) { - const animation = getAnimation( - element, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null; - - // Return null if animation could not be created - if (!animation) { - return null; - } - - let initialPlay = true; - const type = options.type || 'alternate'; - - return (__: MouseEvent | KeyboardEvent) => { - if (selectorCondition && !element.matches(selectorCondition)) return; - if (type === 'alternate') { - if (initialPlay) { - initialPlay = false; - animation.play(); - } else { - animation.reverse(); - } - } else if (type === 'state') { - if (initialPlay) { - initialPlay = false; - animation.play(); - } else { - if (animation.playState === 'running') { - animation.pause(); - } else if (animation.playState !== 'finished') { - // 'idle' OR 'paused' - animation.play(); - } - } - } else { - // type === 'repeat' - // type === 'once' - animation.progress(0); - - if (animation.isCSS) { - animation.onFinish(() => { - // remove the animation from style - fastdom.mutate(() => { - element.dataset.interactEnter = 'done'; - }); - }); - } - - animation.play(); - } - }; -} - -function createTransitionHandler( - element: HTMLElement, - targetController: IInteractionController, - { - effectId, - listContainer, - listItemSelector, - }: TransitionEffect & EffectBase & { effectId: string }, - options: StateParams, - selectorCondition?: string, -) { - const shouldSetStateOnElement = !!listContainer; - - return (__: MouseEvent | KeyboardEvent) => { - if (selectorCondition && !element.matches(selectorCondition)) return; - let item; - if (shouldSetStateOnElement) { - item = element.closest( - `${listContainer} > ${listItemSelector || ''}:has(:scope)`, - ) as HTMLElement | null; - } - - targetController.toggleEffect(effectId, options.method || 'toggle', item); - }; -} - -function addClickHandler( - source: HTMLElement, - target: HTMLElement, - effect: (TimeEffect | TransitionEffect) & EffectBase, - options: StateParams | PointerTriggerParams = {} as StateParams, - { reducedMotion, targetController, selectorCondition, allowA11yTriggers }: InteractOptions, -) { - let handler: ((event: MouseEvent | KeyboardEvent) => void) | null; - let once = false; - - if ( - (effect as TransitionEffect).transition || - (effect as TransitionEffect).transitionProperties - ) { - handler = createTransitionHandler( - target, - targetController!, - effect as TransitionEffect & EffectBase & { effectId: string }, - options as StateParams, - selectorCondition, - ); - } else { - handler = createTimeEffectHandler( - target, - effect as TimeEffect & EffectBase, - options as PointerTriggerParams, - reducedMotion, - selectorCondition, - ); - once = (options as PointerTriggerParams).type === 'once'; - } - - // Early return if animation is null, no event listeners added - if (!handler) { - return; - } - - // Store references to the actual listener functions so we can remove them later - const clickListener = (e: MouseEvent) => { - if ((e as PointerEvent).pointerType) { - handler(e); - } - }; - - const keydownListener = (event: KeyboardEvent) => { - if (event.code === 'Space') { - event.preventDefault(); - handler(event); - } else if (event.code === 'Enter') { - handler(event); - } - }; - - const cleanup = () => { - source.removeEventListener('click', clickListener); - if (allowA11yTriggers) { - source.removeEventListener('keydown', keydownListener); - } - }; - - const handlerObj = { source, target, cleanup }; - - addHandlerToMap(handlerMap, source, handlerObj); - addHandlerToMap(handlerMap, target, handlerObj); - - source.addEventListener('click', clickListener, { passive: true, once }); - - if (allowA11yTriggers) { - source.tabIndex = 0; - source.addEventListener('keydown', keydownListener, { once }); - } -} - -function removeClickHandler(element: HTMLElement) { - removeElementFromHandlerMap(handlerMap, element); -} - -export default { - add: addClickHandler, - remove: removeClickHandler, -}; diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts new file mode 100644 index 00000000..48754abb --- /dev/null +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -0,0 +1,142 @@ +import { getAnimation } from '@wix/motion'; +import type { AnimationGroup } from '@wix/motion'; +import type { + TimeEffect, + TransitionEffect, + StateParams, + PointerTriggerParams, + EffectBase, + IInteractionController, + EventTriggerConfigEnterLeave, +} from '../types'; +import { effectToAnimationOptions } from './utilities'; +import fastdom from 'fastdom'; + +export const EVENT_TRIGGER_PRESETS = { + click: ['click'] as const, + activate: ['click', 'keydown'] as const, + hover: { enter: ['mouseenter'], leave: ['mouseleave'] } as const, + interest: { + enter: ['mouseenter', 'focusin'], + leave: ['mouseleave', 'focusout'], + } as const, +} as const; + +export function createTimeEffectHandler( + element: HTMLElement, + effect: TimeEffect & EffectBase, + options: PointerTriggerParams, + reducedMotion: boolean = false, + selectorCondition?: string, + enterLeave?: EventTriggerConfigEnterLeave, +): ((event: MouseEvent | KeyboardEvent | FocusEvent) => void) | null { + const animation = getAnimation( + element, + effectToAnimationOptions(effect), + undefined, + reducedMotion, + ) as AnimationGroup | null; + + if (!animation) { + return null; + } + + let initialPlay = true; + const type = options.type || 'alternate'; + const enterEvents: string[] = enterLeave?.enter ? [...enterLeave.enter] : []; + const leaveEvents: string[] = enterLeave?.leave ? [...enterLeave.leave] : []; + + return (event: MouseEvent | KeyboardEvent | FocusEvent) => { + if (selectorCondition && !element.matches(selectorCondition)) return; + + const isToggle = !enterLeave; + const isEnter = enterEvents.length > 0 && enterEvents.includes(event.type); + const isLeave = leaveEvents.length > 0 && leaveEvents.includes(event.type); + + if ((isEnter || isToggle) && (type === 'alternate' || type === 'state') && initialPlay) { + initialPlay = false; + animation.play(); + } + if ((isEnter || isToggle) && type === 'alternate' && !initialPlay) { + animation.reverse(); + } + if ((isEnter || isToggle) && type === 'state' && !initialPlay && animation.playState === 'running') { + animation.pause(); + } + if ( + (isEnter || isToggle) && + type === 'state' && + !initialPlay && + animation.playState !== 'running' && + animation.playState !== 'finished' + ) { + animation.play(); + } + if ((isEnter || isToggle) && type !== 'alternate' && type !== 'state') { + animation.progress(0); + if (animation.isCSS) { + animation.onFinish(() => { + fastdom.mutate(() => { + element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = 'done'; + }); + }); + } + animation.play(); + } + + if (isLeave && type === 'alternate') { + animation.reverse(); + } + if (isLeave && type === 'repeat') { + animation.cancel(); + fastdom.mutate(() => { + delete element.dataset.interactEnter; + }); + } + if (isLeave && type === 'state' && animation.playState === 'running') { + animation.pause(); + } + }; +} + +export function createTransitionHandler( + element: HTMLElement, + targetController: IInteractionController, + { + effectId, + listContainer, + listItemSelector, + }: TransitionEffect & EffectBase & { effectId: string }, + options: StateParams, + selectorCondition?: string, + enterLeave?: EventTriggerConfigEnterLeave, +): (event: MouseEvent | KeyboardEvent | FocusEvent) => void { + const shouldSetStateOnElement = !!listContainer; + const method = options.method || 'toggle'; + const isToggle = method === 'toggle'; + const enterEvents: string[] = enterLeave?.enter ? [...enterLeave.enter] : []; + const leaveEvents: string[] = enterLeave?.leave ? [...enterLeave.leave] : []; + + return (event: MouseEvent | KeyboardEvent | FocusEvent) => { + if (selectorCondition && !element.matches(selectorCondition)) return; + + const item: HTMLElement | null | undefined = shouldSetStateOnElement + ? (element.closest( + `${listContainer} > ${listItemSelector || ''}:has(:scope)`, + ) as HTMLElement | null) + : undefined; // undefined when no listContainer so controller delegates to element.toggleEffect + const isToggleMode = !enterLeave; + const isEnter = enterEvents.length > 0 && enterEvents.includes(event.type); + const isLeave = leaveEvents.length > 0 && leaveEvents.includes(event.type); + + if (isToggleMode) { + targetController.toggleEffect(effectId, method, item); + } + if (!isToggleMode && isEnter) { + targetController.toggleEffect(effectId, isToggle ? 'add' : method, item); + } + if (!isToggleMode && isLeave && isToggle) { + targetController.toggleEffect(effectId, 'remove', item); + } + }; +} diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts new file mode 100644 index 00000000..05608a83 --- /dev/null +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -0,0 +1,200 @@ +import type { + TimeEffect, + TransitionEffect, + StateParams, + HandlerObjectMap, + PointerTriggerParams, + EffectBase, + InteractOptions, + EventTriggerConfig, + EventTriggerConfigEnterLeave, + EventTriggerParams, +} from '../types'; +import { addHandlerToMap, removeElementFromHandlerMap } from './utilities'; +import { + createTimeEffectHandler, + createTransitionHandler, +} from './effectHandlers'; + +const handlerMap = new WeakMap() as HandlerObjectMap; + +type GenericEventConfig = { + toggle?: string[]; + enter?: string[]; + leave?: string[]; +}; + +function isEnterLeaveConfigShape( + config: EventTriggerConfig, +): config is EventTriggerConfigEnterLeave { + return typeof config === 'object' && !Array.isArray(config) && ('enter' in config || 'leave' in config); +} + +function createGenericEventConfig(config: EventTriggerConfig): GenericEventConfig { + if (typeof config === 'string') { + return { toggle: [config] }; + } + if (Array.isArray(config)) { + return { toggle: [...config] }; + } + if (isEnterLeaveConfigShape(config)) { + const enter = config.enter ? [...config.enter] : []; + const leave = config.leave ? [...config.leave] : []; + return { enter, leave }; + } + return {}; +} + +function isEnterLeaveMode(genericConfig: GenericEventConfig): boolean { + return (genericConfig.enter?.length ?? 0) > 0 || (genericConfig.leave?.length ?? 0) > 0; +} + +function getEnterLeaveConfig( + genericConfig: GenericEventConfig, +): EventTriggerConfigEnterLeave | undefined { + return isEnterLeaveMode(genericConfig) + ? { enter: genericConfig.enter ?? [], leave: genericConfig.leave ?? [] } + : undefined; +} + +function addEventTriggerHandler( + source: HTMLElement, + target: HTMLElement, + effect: (TimeEffect | TransitionEffect) & EffectBase, + options: EventTriggerParams, + { + reducedMotion, + targetController, + selectorCondition, + allowA11yTriggers, + }: InteractOptions, +) { + const genericConfig = createGenericEventConfig(options.eventConfig); + const isTransition = + (effect as TransitionEffect).transition || + (effect as TransitionEffect).transitionProperties; + + const enterLeave = getEnterLeaveConfig(genericConfig); + + let handler: ((event: MouseEvent | KeyboardEvent | FocusEvent) => void) | null; + let once = false; + + if (isTransition) { + handler = createTransitionHandler( + target, + targetController!, + effect as TransitionEffect & EffectBase & { effectId: string }, + options as StateParams, + selectorCondition, + enterLeave, + ); + } else { + handler = createTimeEffectHandler( + target, + effect as TimeEffect & EffectBase, + options as PointerTriggerParams, + reducedMotion, + selectorCondition, + enterLeave, + ); + once = (options as PointerTriggerParams).type === 'once'; + } + + if (!handler) { + return; + } + + const listeners: { element: HTMLElement; event: string; fn: EventListener }[] = []; + + function addListener( + element: HTMLElement, + event: string, + fn: EventListener, + options?: AddEventListenerOptions, + ) { + element.addEventListener(event, fn, options); + listeners.push({ element, event, fn }); + } + + const focusListener = (e: FocusEvent) => { + if (!source.contains(e.relatedTarget as HTMLElement)) { + (handler as (e: FocusEvent) => void)(e); + } + }; + + const clickListener = (e: MouseEvent) => { + if ((e as PointerEvent).pointerType) { + (handler as (e: MouseEvent) => void)(e); + } + }; + + const keydownListener = (e: KeyboardEvent) => { + if (e.code === 'Space') { + e.preventDefault(); + (handler as (e: KeyboardEvent) => void)(e); + } else if (e.code === 'Enter') { + (handler as (e: KeyboardEvent) => void)(e); + } + }; + + const cleanup = () => { + listeners.forEach(({ element, event, fn }) => { + element.removeEventListener(event, fn); + }); + }; + + const handlerObj = { source, target, cleanup }; + addHandlerToMap(handlerMap, source, handlerObj); + addHandlerToMap(handlerMap, target, handlerObj); + + const isEnterLeave = isEnterLeaveMode(genericConfig); + if (isEnterLeave) { + const enter = genericConfig.enter ?? []; + const leaveEvents = genericConfig.leave ?? []; + enter.forEach((eventType) => { + if (eventType === 'focusin') { + if (allowA11yTriggers) { + source.tabIndex = 0; + addListener(source, eventType, focusListener as EventListener, { once }); + } + return; + } + addListener(source, eventType, handler as EventListener, { passive: true, once }); + }); + const addLeaveListeners = isTransition + ? (options as StateParams).method === 'toggle' + : (options as PointerTriggerParams).type !== 'once'; + if (addLeaveListeners) { + leaveEvents.forEach((eventType) => { + if (eventType === 'focusout') { + if (allowA11yTriggers) { + addListener(source, eventType, focusListener as EventListener, { once }); + } + return; + } + addListener(source, eventType, handler as EventListener, { passive: true }); + }); + } + } else { + const events = genericConfig.toggle ?? []; + events.forEach((eventType) => { + const opts = { passive: true, once }; + if (eventType === 'click') { + addListener(source, 'click', clickListener as EventListener, opts); + } else if (eventType === 'keydown') { + addListener(source, 'keydown', keydownListener as EventListener, { once }); + } else { + addListener(source, eventType, handler as EventListener, opts); + } + }); + } +} + +function removeEventTriggerHandler(element: HTMLElement) { + removeElementFromHandlerMap(handlerMap, element); +} + +export default { + add: addEventTriggerHandler, + remove: removeEventTriggerHandler, +}; diff --git a/packages/interact/src/handlers/hover.ts b/packages/interact/src/handlers/hover.ts deleted file mode 100644 index f7eec11c..00000000 --- a/packages/interact/src/handlers/hover.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { AnimationGroup } from '@wix/motion'; -import { getAnimation } from '@wix/motion'; -import type { - TimeEffect, - TransitionEffect, - StateParams, - HandlerObjectMap, - PointerTriggerParams, - EffectBase, - IInteractionController, - InteractOptions, -} from '../types'; -import { - effectToAnimationOptions, - addHandlerToMap, - removeElementFromHandlerMap, -} from './utilities'; -import fastdom from 'fastdom'; - -const handlerMap = new WeakMap() as HandlerObjectMap; - -function createTimeEffectHandler( - element: HTMLElement, - effect: TimeEffect & EffectBase, - options: PointerTriggerParams, - reducedMotion: boolean = false, - selectorCondition?: string, -) { - const animation = getAnimation( - element, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null; - - // Return null if animation could not be created - if (!animation) { - return null; - } - - const type = options.type || 'alternate'; - let initialPlay = true; - - return (event: MouseEvent | FocusEvent) => { - if (selectorCondition && !element.matches(selectorCondition)) return; - if (event.type === 'mouseenter' || event.type === 'focusin') { - if (type === 'alternate') { - if (initialPlay) { - initialPlay = false; - animation.play(); - } else { - animation.reverse(); - } - } else if (type === 'state') { - if (animation.playState !== 'finished') { - // 'idle' OR 'paused' - animation.play(); - } - } else { - // type === 'repeat' - // type === 'once' - animation.progress(0); - - if (animation.isCSS) { - animation.onFinish(() => { - // remove the animation from style - fastdom.mutate(() => { - element.dataset.motionEnter = 'done'; - }); - }); - } - - animation.play(); - } - } else if (event.type === 'mouseleave' || event.type === 'focusout') { - if (type === 'alternate') { - animation.reverse(); - } else if (type === 'repeat') { - animation.cancel(); - fastdom.mutate(() => { - delete element.dataset.interactEnter; - }); - } else if (type === 'state') { - if (animation.playState === 'running') { - animation.pause(); - } - } - } - }; -} - -function createTransitionHandler( - element: HTMLElement, - targetController: IInteractionController, - { - effectId, - listContainer, - listItemSelector, - }: TransitionEffect & EffectBase & { effectId: string }, - options: StateParams, - selectorCondition?: string, -) { - const method = options.method || 'toggle'; - const isToggle = method === 'toggle'; - const shouldSetStateOnElement = !!listContainer; - - return (event: MouseEvent | FocusEvent) => { - if (selectorCondition && !element.matches(selectorCondition)) return; - let item; - if (shouldSetStateOnElement) { - item = element.closest( - `${listContainer} > ${listItemSelector || ''}:has(:scope)`, - ) as HTMLElement | null; - } - - if (event.type === 'mouseenter' || event.type === 'focusin') { - const method_ = isToggle ? 'add' : method; - targetController.toggleEffect(effectId, method_, item); - } else if ((event.type === 'mouseleave' || event.type === 'focusout') && isToggle) { - targetController.toggleEffect(effectId, 'remove', item); - } - }; -} - -function addHoverHandler( - source: HTMLElement, - target: HTMLElement, - effect: (TransitionEffect | TimeEffect) & EffectBase, - options: StateParams | PointerTriggerParams = {}, - { reducedMotion, targetController, selectorCondition, allowA11yTriggers }: InteractOptions, -) { - let handler: ((event: MouseEvent | FocusEvent) => void) | null; - let isStateTrigger = false; - let once = false; - - if ( - (effect as TransitionEffect).transition || - (effect as TransitionEffect).transitionProperties - ) { - handler = createTransitionHandler( - target, - targetController!, - effect as TransitionEffect & EffectBase & { effectId: string }, - options as StateParams, - selectorCondition, - ); - isStateTrigger = true; - } else { - handler = createTimeEffectHandler( - target, - effect as TimeEffect & EffectBase, - options as PointerTriggerParams, - reducedMotion, - selectorCondition, - ); - once = (options as PointerTriggerParams).type === 'once'; - } - - // Early return if animation is null, no event listeners added - if (!handler) { - return; - } - - const focusinListener = (event: FocusEvent) => { - if (!source.contains(event.relatedTarget as HTMLElement)) { - handler(event); - } - }; - - const focusoutListener = (event: FocusEvent) => { - if (!source.contains(event.relatedTarget as HTMLElement)) { - handler(event); - } - }; - - const cleanup = () => { - source.removeEventListener('mouseenter', handler); - source.removeEventListener('mouseleave', handler); - if (allowA11yTriggers) { - source.removeEventListener('focusin', focusinListener); - source.removeEventListener('focusout', focusoutListener); - } - }; - - const handlerObj = { source, target, cleanup }; - - addHandlerToMap(handlerMap, source, handlerObj); - addHandlerToMap(handlerMap, target, handlerObj); - - if (allowA11yTriggers) { - source.tabIndex = 0; - source.addEventListener('focusin', focusinListener, { once }); - } - - source.addEventListener('mouseenter', handler, { passive: true, once }); - - const addLeave = isStateTrigger - ? ((options as StateParams).method || 'toggle') === 'toggle' - : (options as PointerTriggerParams).type !== 'once'; - if (addLeave) { - source.addEventListener('mouseleave', handler, { passive: true }); - - if (allowA11yTriggers) { - source.addEventListener('focusout', focusoutListener, { once }); - } - } -} - -function removeHoverHandler(element: HTMLElement) { - removeElementFromHandlerMap(handlerMap, element); -} - -export default { - add: addHoverHandler, - remove: removeHoverHandler, -}; diff --git a/packages/interact/src/handlers/index.ts b/packages/interact/src/handlers/index.ts index 3ff1dd34..5f6e9448 100644 --- a/packages/interact/src/handlers/index.ts +++ b/packages/interact/src/handlers/index.ts @@ -1,5 +1,6 @@ import type { Effect, + EventTriggerConfig, InteractOptions, PointerTriggerParams, StateParams, @@ -8,36 +9,63 @@ import type { } from '../types'; import viewEnterHandler from './viewEnter'; import viewProgressHandler from './viewProgress'; -import hoverHandler from './hover'; -import clickHandler from './click'; import pointerMoveHandler from './pointerMove'; import animationEndHandler from './animationEnd'; +import eventTrigger from './eventTrigger'; +import { EVENT_TRIGGER_PRESETS } from './effectHandlers'; -function withA11y(handler: T): T { - return { - add: ( - source: HTMLElement, - target: HTMLElement, - effect: Effect, - options: StateParams | PointerTriggerParams, - interactOptions?: InteractOptions, - ) => - handler.add(source, target, effect, options, { - ...interactOptions, - allowA11yTriggers: true, - }), - remove: handler.remove, - } as T; +type EventConfigOrGetter = + | (typeof EVENT_TRIGGER_PRESETS)[keyof typeof EVENT_TRIGGER_PRESETS] + | ((interactOptions?: InteractOptions) => EventTriggerConfig); + +function withEventTriggerConfig(eventConfigOrGetter: EventConfigOrGetter) { + return ( + source: HTMLElement, + target: HTMLElement, + effect: Effect, + options: StateParams | PointerTriggerParams, + interactOptions?: InteractOptions, + ) => { + const eventConfig = + typeof eventConfigOrGetter === 'function' + ? eventConfigOrGetter(interactOptions) + : eventConfigOrGetter; + eventTrigger.add(source, target, effect, { ...options, eventConfig }, interactOptions ?? {}); + }; +} + +function getClickEventConfig(interactOptions?: InteractOptions) { + return interactOptions?.allowA11yTriggers + ? EVENT_TRIGGER_PRESETS.activate + : EVENT_TRIGGER_PRESETS.click; +} + +function getHoverEventConfig(interactOptions?: InteractOptions) { + return interactOptions?.allowA11yTriggers + ? EVENT_TRIGGER_PRESETS.interest + : EVENT_TRIGGER_PRESETS.hover; } export default { viewEnter: viewEnterHandler, - hover: hoverHandler, - click: clickHandler, + hover: { + add: withEventTriggerConfig(getHoverEventConfig), + remove: eventTrigger.remove, + }, + click: { + add: withEventTriggerConfig(getClickEventConfig), + remove: eventTrigger.remove, + }, pageVisible: viewEnterHandler, animationEnd: animationEndHandler, viewProgress: viewProgressHandler, pointerMove: pointerMoveHandler, - activate: withA11y(clickHandler), - interest: withA11y(hoverHandler), + activate: { + add: withEventTriggerConfig(EVENT_TRIGGER_PRESETS.activate), + remove: eventTrigger.remove, + }, + interest: { + add: withEventTriggerConfig(EVENT_TRIGGER_PRESETS.interest), + remove: eventTrigger.remove, + }, } as TriggerHandlerMap; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 7bffa724..3b15e2d9 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -34,6 +34,18 @@ export type TriggerType = | 'activate' | 'interest'; +export type EventTriggerKind = 'toggle' | 'enterLeave'; +export type EventTriggerConfigToggle = readonly string[] | string[]; +export type EventTriggerConfigEnterLeave = { + enter?: readonly string[]; + leave?: readonly string[]; +}; + +export type EventTriggerConfig = + | string + | EventTriggerConfigToggle + | EventTriggerConfigEnterLeave; + export type ViewEnterType = 'once' | 'repeat' | 'alternate' | 'state'; export type TransitionMethod = 'add' | 'remove' | 'toggle' | 'clear'; @@ -46,6 +58,10 @@ export type PointerTriggerParams = { type?: ViewEnterType | 'state'; }; +export type EventTriggerParams = (StateParams | PointerTriggerParams) & { + eventConfig: EventTriggerConfig; +}; + export type ViewEnterParams = { type?: ViewEnterType; threshold?: number; diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 62a96fa8..1d0d4ba6 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -2879,6 +2879,7 @@ describe('interact (mini)', () => { describe('interest trigger', () => { it('should add focusin listener alongside mouseenter', () => { + Interact.setup({ allowA11yTriggers: true }); Interact.create(getA11yConfig('interest', 'interest-test')); a11yElement = document.createElement('div'); diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index b5730af5..c5f0cc86 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -2769,6 +2769,7 @@ describe('interact (web)', () => { describe('interest trigger', () => { it('should add focusin listener alongside mouseenter', () => { + Interact.setup({ allowA11yTriggers: true }); Interact.create(getA11yConfig('interest', 'interest-test'), { useCutsomElement: true }); a11yElement = document.createElement('interact-element') as IInteractElement; From 7645a6ecf2cc68f73b44be606c190cbaa99f5c20 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Wed, 18 Feb 2026 12:43:08 +0200 Subject: [PATCH 2/8] yarn format:check --- cleanCode.md | 64 +++++++++---------- .../interact/src/handlers/effectHandlers.ts | 7 +- .../interact/src/handlers/eventTrigger.ts | 19 ++---- packages/interact/src/types.ts | 5 +- 4 files changed, 45 insertions(+), 50 deletions(-) diff --git a/cleanCode.md b/cleanCode.md index d60e98e2..628ae331 100644 --- a/cleanCode.md +++ b/cleanCode.md @@ -7,12 +7,12 @@ Ordered by priority — higher rules take precedence when rules conflict. ## 1. Minimal Change Principle ⚡ Highest Priority -* **Do not touch working code** unless strictly required for the requested behavior. -* **Never refactor, rename, reorder, or restructure** unrelated code. -* Prefer the **smallest possible diff**. -* Assume all existing code is **intentional and correct**. -* **Match the existing structure.** Use the same control flow, naming, patterns, and conventions as the code you're changing or the nearest similar file. Do not introduce new abstractions (wrapper factories, generic helpers) when the existing code uses direct approaches. -* **Preserve intent over style.** Do not rewrite code solely to match these rules. These rules must never override domain meaning or deliberate design choices. +- **Do not touch working code** unless strictly required for the requested behavior. +- **Never refactor, rename, reorder, or restructure** unrelated code. +- Prefer the **smallest possible diff**. +- Assume all existing code is **intentional and correct**. +- **Match the existing structure.** Use the same control flow, naming, patterns, and conventions as the code you're changing or the nearest similar file. Do not introduce new abstractions (wrapper factories, generic helpers) when the existing code uses direct approaches. +- **Preserve intent over style.** Do not rewrite code solely to match these rules. These rules must never override domain meaning or deliberate design choices. > Change only what is directly linked to the requested behavior — nothing more. When in doubt, keep the same structure as the original. @@ -20,20 +20,20 @@ Ordered by priority — higher rules take precedence when rules conflict. ## 2. Clarity Over Performance -* Prefer **simple, explicit, readable code** over micro-optimizations. -* Avoid clever tricks, compressed expressions, or obscure patterns. -* Optimize **only when explicitly requested** or clearly critical. -* Prefer direct boolean forms: `x === 'toggle'` over `x !== 'toggle' ? false : true`. +- Prefer **simple, explicit, readable code** over micro-optimizations. +- Avoid clever tricks, compressed expressions, or obscure patterns. +- Optimize **only when explicitly requested** or clearly critical. +- Prefer direct boolean forms: `x === 'toggle'` over `x !== 'toggle' ? false : true`. --- ## 3. Control Flow: Flat and Explicit -* Prefer **flattened control flow** over nested `if` trees. -* Unite `if` statements that lead to the same behavior. -* Use **early guard returns** to simplify logic. -* If flattening hurts readability, extract **well-named helpers**. -* Prefer **a single final return** per function; multiple early returns are fine for guards and impossible states. +- Prefer **flattened control flow** over nested `if` trees. +- Unite `if` statements that lead to the same behavior. +- Use **early guard returns** to simplify logic. +- If flattening hurts readability, extract **well-named helpers**. +- Prefer **a single final return** per function; multiple early returns are fine for guards and impossible states. ### Example @@ -61,18 +61,18 @@ if (!b && c) doY(); ## 4. Short, Focused Functions -* Functions should fit on **one screen** (~100 lines max). -* If a function does more than one thing, mixes abstraction levels, or needs explanation → extract helpers. +- Functions should fit on **one screen** (~100 lines max). +- If a function does more than one thing, mixes abstraction levels, or needs explanation → extract helpers. -> If you need comments to explain *what* a function does, it's too big. +> If you need comments to explain _what_ a function does, it's too big. --- ## 5. Separation of Concerns -* High-level logic must not contain low-level details. -* Extract validation, parsing, formatting, and calculations into helpers. -* **Helpers are a feature, not a smell.** Prefer named helpers over inline complexity. Names should explain **why**, not **how**. +- High-level logic must not contain low-level details. +- Extract validation, parsing, formatting, and calculations into helpers. +- **Helpers are a feature, not a smell.** Prefer named helpers over inline complexity. Names should explain **why**, not **how**. ### Example @@ -94,21 +94,21 @@ if (isEligibleUser(user)) { ... } ## 6. Don't Repeat Yourself (DRY) -* Never duplicate logic, conditions, or transformations. -* Extract and reuse — even if the helper feels trivial. +- Never duplicate logic, conditions, or transformations. +- Extract and reuse — even if the helper feels trivial. --- ## 7. Immutability First -* Always prefer `const` over `let`. -* Avoid mutation unless unavoidable; return new values instead. +- Always prefer `const` over `let`. +- Avoid mutation unless unavoidable; return new values instead. --- ## 8. Limit Function Arguments -* Avoid more than **4 arguments**. Group related arguments into objects or extract helpers. +- Avoid more than **4 arguments**. Group related arguments into objects or extract helpers. > Many arguments signal unclear boundaries or mixed concerns. @@ -116,15 +116,15 @@ if (isEligibleUser(user)) { ... } ## 9. Comments: Only When Necessary -* Add comments only when **strictly necessary** (non-obvious workarounds, critical "why" that code/naming cannot express). -* If a comment restates the code, remove it. If it explains *what*, improve the code instead. +- Add comments only when **strictly necessary** (non-obvious workarounds, critical "why" that code/naming cannot express). +- If a comment restates the code, remove it. If it explains _what_, improve the code instead. --- ## 10. Prefer Loose Data Shapes Over Discriminated Unions -* Prefer a **single object with optional properties** over discriminated unions when the code only checks "which keys are present." -* Don't add structure (e.g. a `kind` field) that exists only for the type system. +- Prefer a **single object with optional properties** over discriminated unions when the code only checks "which keys are present." +- Don't add structure (e.g. a `kind` field) that exists only for the type system. ### Example @@ -147,13 +147,13 @@ type NotifyConfig = { at?: Date; ms?: number; immediate?: true }; ## 11. Avoid Unnecessary Parameterization -* Don't add a parameter when the only value ever passed is created in the same scope. Close over it instead. +- Don't add a parameter when the only value ever passed is created in the same scope. Close over it instead. --- ## 12. Declare Variables Near Usage -* Declare variables **close to where they're first used**, not at the top of a block. +- Declare variables **close to where they're first used**, not at the top of a block. --- diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index 48754abb..6dcbb8e6 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -60,7 +60,12 @@ export function createTimeEffectHandler( if ((isEnter || isToggle) && type === 'alternate' && !initialPlay) { animation.reverse(); } - if ((isEnter || isToggle) && type === 'state' && !initialPlay && animation.playState === 'running') { + if ( + (isEnter || isToggle) && + type === 'state' && + !initialPlay && + animation.playState === 'running' + ) { animation.pause(); } if ( diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 05608a83..086b0a4b 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -11,10 +11,7 @@ import type { EventTriggerParams, } from '../types'; import { addHandlerToMap, removeElementFromHandlerMap } from './utilities'; -import { - createTimeEffectHandler, - createTransitionHandler, -} from './effectHandlers'; +import { createTimeEffectHandler, createTransitionHandler } from './effectHandlers'; const handlerMap = new WeakMap() as HandlerObjectMap; @@ -27,7 +24,9 @@ type GenericEventConfig = { function isEnterLeaveConfigShape( config: EventTriggerConfig, ): config is EventTriggerConfigEnterLeave { - return typeof config === 'object' && !Array.isArray(config) && ('enter' in config || 'leave' in config); + return ( + typeof config === 'object' && !Array.isArray(config) && ('enter' in config || 'leave' in config) + ); } function createGenericEventConfig(config: EventTriggerConfig): GenericEventConfig { @@ -62,17 +61,11 @@ function addEventTriggerHandler( target: HTMLElement, effect: (TimeEffect | TransitionEffect) & EffectBase, options: EventTriggerParams, - { - reducedMotion, - targetController, - selectorCondition, - allowA11yTriggers, - }: InteractOptions, + { reducedMotion, targetController, selectorCondition, allowA11yTriggers }: InteractOptions, ) { const genericConfig = createGenericEventConfig(options.eventConfig); const isTransition = - (effect as TransitionEffect).transition || - (effect as TransitionEffect).transitionProperties; + (effect as TransitionEffect).transition || (effect as TransitionEffect).transitionProperties; const enterLeave = getEnterLeaveConfig(genericConfig); diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 3b15e2d9..c442145f 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -41,10 +41,7 @@ export type EventTriggerConfigEnterLeave = { leave?: readonly string[]; }; -export type EventTriggerConfig = - | string - | EventTriggerConfigToggle - | EventTriggerConfigEnterLeave; +export type EventTriggerConfig = string | EventTriggerConfigToggle | EventTriggerConfigEnterLeave; export type ViewEnterType = 'once' | 'repeat' | 'alternate' | 'state'; From 89e370aa10bff35c5bc522d3edc9dbf492f9ba76 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Wed, 18 Feb 2026 15:20:06 +0200 Subject: [PATCH 3/8] refactor allowA11yTriggers out of eventHandler --- .../interact/src/handlers/eventTrigger.ts | 12 ++---- packages/interact/src/handlers/index.ts | 37 +++++++------------ 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 086b0a4b..4f35d7db 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -61,7 +61,7 @@ function addEventTriggerHandler( target: HTMLElement, effect: (TimeEffect | TransitionEffect) & EffectBase, options: EventTriggerParams, - { reducedMotion, targetController, selectorCondition, allowA11yTriggers }: InteractOptions, + { reducedMotion, targetController, selectorCondition }: InteractOptions, ) { const genericConfig = createGenericEventConfig(options.eventConfig); const isTransition = @@ -146,10 +146,8 @@ function addEventTriggerHandler( const leaveEvents = genericConfig.leave ?? []; enter.forEach((eventType) => { if (eventType === 'focusin') { - if (allowA11yTriggers) { - source.tabIndex = 0; - addListener(source, eventType, focusListener as EventListener, { once }); - } + source.tabIndex = 0; + addListener(source, eventType, focusListener as EventListener, { once }); return; } addListener(source, eventType, handler as EventListener, { passive: true, once }); @@ -160,9 +158,7 @@ function addEventTriggerHandler( if (addLeaveListeners) { leaveEvents.forEach((eventType) => { if (eventType === 'focusout') { - if (allowA11yTriggers) { - addListener(source, eventType, focusListener as EventListener, { once }); - } + addListener(source, eventType, focusListener as EventListener, { once }); return; } addListener(source, eventType, handler as EventListener, { passive: true }); diff --git a/packages/interact/src/handlers/index.ts b/packages/interact/src/handlers/index.ts index 5f6e9448..b314a8ad 100644 --- a/packages/interact/src/handlers/index.ts +++ b/packages/interact/src/handlers/index.ts @@ -1,6 +1,5 @@ import type { Effect, - EventTriggerConfig, InteractOptions, PointerTriggerParams, StateParams, @@ -14,11 +13,13 @@ import animationEndHandler from './animationEnd'; import eventTrigger from './eventTrigger'; import { EVENT_TRIGGER_PRESETS } from './effectHandlers'; -type EventConfigOrGetter = - | (typeof EVENT_TRIGGER_PRESETS)[keyof typeof EVENT_TRIGGER_PRESETS] - | ((interactOptions?: InteractOptions) => EventTriggerConfig); +const a11yTriggerOverrides = { + click: EVENT_TRIGGER_PRESETS.activate, + hover: EVENT_TRIGGER_PRESETS.interest, +} as const; -function withEventTriggerConfig(eventConfigOrGetter: EventConfigOrGetter) { +function withEventTriggerConfig(presetKey: keyof typeof EVENT_TRIGGER_PRESETS) { + const preset = EVENT_TRIGGER_PRESETS[presetKey]; return ( source: HTMLElement, target: HTMLElement, @@ -27,33 +28,21 @@ function withEventTriggerConfig(eventConfigOrGetter: EventConfigOrGetter) { interactOptions?: InteractOptions, ) => { const eventConfig = - typeof eventConfigOrGetter === 'function' - ? eventConfigOrGetter(interactOptions) - : eventConfigOrGetter; + interactOptions?.allowA11yTriggers && presetKey in a11yTriggerOverrides + ? a11yTriggerOverrides[presetKey as keyof typeof a11yTriggerOverrides] + : preset; eventTrigger.add(source, target, effect, { ...options, eventConfig }, interactOptions ?? {}); }; } -function getClickEventConfig(interactOptions?: InteractOptions) { - return interactOptions?.allowA11yTriggers - ? EVENT_TRIGGER_PRESETS.activate - : EVENT_TRIGGER_PRESETS.click; -} - -function getHoverEventConfig(interactOptions?: InteractOptions) { - return interactOptions?.allowA11yTriggers - ? EVENT_TRIGGER_PRESETS.interest - : EVENT_TRIGGER_PRESETS.hover; -} - export default { viewEnter: viewEnterHandler, hover: { - add: withEventTriggerConfig(getHoverEventConfig), + add: withEventTriggerConfig('hover'), remove: eventTrigger.remove, }, click: { - add: withEventTriggerConfig(getClickEventConfig), + add: withEventTriggerConfig('click'), remove: eventTrigger.remove, }, pageVisible: viewEnterHandler, @@ -61,11 +50,11 @@ export default { viewProgress: viewProgressHandler, pointerMove: pointerMoveHandler, activate: { - add: withEventTriggerConfig(EVENT_TRIGGER_PRESETS.activate), + add: withEventTriggerConfig('activate'), remove: eventTrigger.remove, }, interest: { - add: withEventTriggerConfig(EVENT_TRIGGER_PRESETS.interest), + add: withEventTriggerConfig('interest'), remove: eventTrigger.remove, }, } as TriggerHandlerMap; From 0a5d2db2bd046da22891af6cd180b57af6ee2444 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Thu, 19 Feb 2026 09:01:50 +0200 Subject: [PATCH 4/8] fix if block fall-through issue --- .../interact/src/handlers/effectHandlers.ts | 79 +++++++++---------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index 6dcbb8e6..b69177d6 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -53,53 +53,46 @@ export function createTimeEffectHandler( const isEnter = enterEvents.length > 0 && enterEvents.includes(event.type); const isLeave = leaveEvents.length > 0 && leaveEvents.includes(event.type); - if ((isEnter || isToggle) && (type === 'alternate' || type === 'state') && initialPlay) { - initialPlay = false; - animation.play(); - } - if ((isEnter || isToggle) && type === 'alternate' && !initialPlay) { - animation.reverse(); - } - if ( - (isEnter || isToggle) && - type === 'state' && - !initialPlay && - animation.playState === 'running' - ) { - animation.pause(); - } - if ( - (isEnter || isToggle) && - type === 'state' && - !initialPlay && - animation.playState !== 'running' && - animation.playState !== 'finished' - ) { - animation.play(); - } - if ((isEnter || isToggle) && type !== 'alternate' && type !== 'state') { - animation.progress(0); - if (animation.isCSS) { - animation.onFinish(() => { - fastdom.mutate(() => { - element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = 'done'; + if (isEnter || isToggle) { + if (type === 'alternate' || type === 'state') { + if (initialPlay) { + initialPlay = false; + animation.play(); + } else if (type === 'alternate') { + animation.reverse(); + } else if (type === 'state') { + if (animation.playState === 'running') { + animation.pause(); + } else if (animation.playState !== 'finished') { + animation.play(); + } + } + } else { + animation.progress(0); + if (animation.isCSS) { + animation.onFinish(() => { + fastdom.mutate(() => { + element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = + 'done'; + }); }); - }); + } + animation.play(); } - animation.play(); + return; } - if (isLeave && type === 'alternate') { - animation.reverse(); - } - if (isLeave && type === 'repeat') { - animation.cancel(); - fastdom.mutate(() => { - delete element.dataset.interactEnter; - }); - } - if (isLeave && type === 'state' && animation.playState === 'running') { - animation.pause(); + if (isLeave) { + if (type === 'alternate') { + animation.reverse(); + } else if (type === 'repeat') { + animation.cancel(); + fastdom.mutate(() => { + delete element.dataset.interactEnter; + }); + } else if (type === 'state' && animation.playState === 'running') { + animation.pause(); + } } }; } From c1a297b2d0934d0394b13ab8ff5a6832b9126976 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Thu, 19 Feb 2026 09:07:30 +0200 Subject: [PATCH 5/8] prittify and revert unrelated test files --- packages/interact/src/handlers/effectHandlers.ts | 3 +-- packages/interact/test/mini.spec.ts | 1 - packages/interact/test/web.spec.ts | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index b69177d6..ceba43b3 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -72,8 +72,7 @@ export function createTimeEffectHandler( if (animation.isCSS) { animation.onFinish(() => { fastdom.mutate(() => { - element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = - 'done'; + element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = 'done'; }); }); } diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 1d0d4ba6..62a96fa8 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -2879,7 +2879,6 @@ describe('interact (mini)', () => { describe('interest trigger', () => { it('should add focusin listener alongside mouseenter', () => { - Interact.setup({ allowA11yTriggers: true }); Interact.create(getA11yConfig('interest', 'interest-test')); a11yElement = document.createElement('div'); diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index c5f0cc86..b5730af5 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -2769,7 +2769,6 @@ describe('interact (web)', () => { describe('interest trigger', () => { it('should add focusin listener alongside mouseenter', () => { - Interact.setup({ allowA11yTriggers: true }); Interact.create(getA11yConfig('interest', 'interest-test'), { useCutsomElement: true }); a11yElement = document.createElement('interact-element') as IInteractElement; From dbf29e01ee5240b53f446378cac452b997523a7e Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Tue, 24 Feb 2026 09:16:16 +0200 Subject: [PATCH 6/8] CR changes --- cleanCode.md => .cursor/rules/cleanCode.mdc | 7 ++ .../interact/src/handlers/effectHandlers.ts | 32 +++--- .../interact/src/handlers/eventTrigger.ts | 107 ++++++++++-------- 3 files changed, 86 insertions(+), 60 deletions(-) rename cleanCode.md => .cursor/rules/cleanCode.mdc (94%) diff --git a/cleanCode.md b/.cursor/rules/cleanCode.mdc similarity index 94% rename from cleanCode.md rename to .cursor/rules/cleanCode.mdc index 628ae331..00675121 100644 --- a/cleanCode.md +++ b/.cursor/rules/cleanCode.mdc @@ -1,8 +1,15 @@ +--- +description: Clean code rules for JavaScript (apply only when user explicitly requests via @cleanCode) +alwaysApply: false +--- + # Clean Code Rules for LLM (JavaScript) Behavioral constraints for LLMs modifying or generating JavaScript code. Ordered by priority — higher rules take precedence when rules conflict. +> **Note**: Apply this rule only when the user explicitly requests it (e.g., by @-mentioning @cleanCode or asking for clean code guidance). + --- ## 1. Minimal Change Principle ⚡ Highest Priority diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index ceba43b3..796de8c2 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -29,7 +29,7 @@ export function createTimeEffectHandler( reducedMotion: boolean = false, selectorCondition?: string, enterLeave?: EventTriggerConfigEnterLeave, -): ((event: MouseEvent | KeyboardEvent | FocusEvent) => void) | null { +): ((event: Event) => void) | null { const animation = getAnimation( element, effectToAnimationOptions(effect), @@ -43,10 +43,10 @@ export function createTimeEffectHandler( let initialPlay = true; const type = options.type || 'alternate'; - const enterEvents: string[] = enterLeave?.enter ? [...enterLeave.enter] : []; - const leaveEvents: string[] = enterLeave?.leave ? [...enterLeave.leave] : []; + const enterEvents = enterLeave?.enter ?? []; + const leaveEvents = enterLeave?.leave ?? []; - return (event: MouseEvent | KeyboardEvent | FocusEvent) => { + return (event: Event) => { if (selectorCondition && !element.matches(selectorCondition)) return; const isToggle = !enterLeave; @@ -69,10 +69,11 @@ export function createTimeEffectHandler( } } else { animation.progress(0); + delete element.dataset.interactEnter; if (animation.isCSS) { animation.onFinish(() => { fastdom.mutate(() => { - element.dataset[enterLeave ? 'motionEnter' : 'interactEnter'] = 'done'; + element.dataset.interactEnter = 'done'; }); }); } @@ -107,14 +108,14 @@ export function createTransitionHandler( options: StateParams, selectorCondition?: string, enterLeave?: EventTriggerConfigEnterLeave, -): (event: MouseEvent | KeyboardEvent | FocusEvent) => void { +): (event: Event) => void { const shouldSetStateOnElement = !!listContainer; const method = options.method || 'toggle'; const isToggle = method === 'toggle'; - const enterEvents: string[] = enterLeave?.enter ? [...enterLeave.enter] : []; - const leaveEvents: string[] = enterLeave?.leave ? [...enterLeave.leave] : []; + const enterEvents = enterLeave?.enter ?? []; + const leaveEvents = enterLeave?.leave ?? []; - return (event: MouseEvent | KeyboardEvent | FocusEvent) => { + return (event: Event) => { if (selectorCondition && !element.matches(selectorCondition)) return; const item: HTMLElement | null | undefined = shouldSetStateOnElement @@ -128,12 +129,13 @@ export function createTransitionHandler( if (isToggleMode) { targetController.toggleEffect(effectId, method, item); - } - if (!isToggleMode && isEnter) { - targetController.toggleEffect(effectId, isToggle ? 'add' : method, item); - } - if (!isToggleMode && isLeave && isToggle) { - targetController.toggleEffect(effectId, 'remove', item); + } else { + if (isEnter) { + targetController.toggleEffect(effectId, isToggle ? 'add' : method, item); + } + if (isLeave && isToggle) { + targetController.toggleEffect(effectId, 'remove', item); + } } }; } diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 4f35d7db..967f6446 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -15,6 +15,56 @@ import { createTimeEffectHandler, createTransitionHandler } from './effectHandle const handlerMap = new WeakMap() as HandlerObjectMap; +type EventHandler = (event: Event) => void; + +function createFocusListener(source: HTMLElement, handler: EventHandler): EventListener { + return (e: Event) => { + const ev = e as FocusEvent; + if (!source.contains(ev.relatedTarget as HTMLElement)) { + handler(ev); + } + }; +} + +function createClickListener(handler: EventHandler): EventListener { + return (e: Event) => { + const ev = e as MouseEvent; + if ((ev as PointerEvent).pointerType) { + handler(ev); + } + }; +} + +function createKeydownListener(handler: EventHandler): EventListener { + return (e: Event) => { + const ev = e as KeyboardEvent; + if (ev.code === 'Space') { + ev.preventDefault(); + handler(ev); + } else if (ev.code === 'Enter') { + handler(ev); + } + }; +} + +const LISTENER_FACTORY_BY_EVENT_TYPE: Partial< + Record EventListener> +> = { + focusin: (source, handler) => createFocusListener(source, handler), + focusout: (source, handler) => createFocusListener(source, handler), + click: (_source, handler) => createClickListener(handler), + keydown: (_source, handler) => createKeydownListener(handler), +}; + +function getListenerForEventType( + event: string, + source: HTMLElement, + handler: EventHandler, +): EventListener { + const factory = LISTENER_FACTORY_BY_EVENT_TYPE[event]; + return factory ? factory(source, handler) : createKeydownListener(handler); +} + type GenericEventConfig = { toggle?: string[]; enter?: string[]; @@ -69,7 +119,7 @@ function addEventTriggerHandler( const enterLeave = getEnterLeaveConfig(genericConfig); - let handler: ((event: MouseEvent | KeyboardEvent | FocusEvent) => void) | null; + let handler: EventHandler | null; let once = false; if (isTransition) { @@ -97,39 +147,15 @@ function addEventTriggerHandler( return; } + const resolvedHandler = handler; const listeners: { element: HTMLElement; event: string; fn: EventListener }[] = []; - function addListener( - element: HTMLElement, - event: string, - fn: EventListener, - options?: AddEventListenerOptions, - ) { + function addListener(element: HTMLElement, event: string, options?: AddEventListenerOptions) { + const fn = getListenerForEventType(event, source, resolvedHandler); element.addEventListener(event, fn, options); listeners.push({ element, event, fn }); } - const focusListener = (e: FocusEvent) => { - if (!source.contains(e.relatedTarget as HTMLElement)) { - (handler as (e: FocusEvent) => void)(e); - } - }; - - const clickListener = (e: MouseEvent) => { - if ((e as PointerEvent).pointerType) { - (handler as (e: MouseEvent) => void)(e); - } - }; - - const keydownListener = (e: KeyboardEvent) => { - if (e.code === 'Space') { - e.preventDefault(); - (handler as (e: KeyboardEvent) => void)(e); - } else if (e.code === 'Enter') { - (handler as (e: KeyboardEvent) => void)(e); - } - }; - const cleanup = () => { listeners.forEach(({ element, event, fn }) => { element.removeEventListener(event, fn); @@ -140,17 +166,14 @@ function addEventTriggerHandler( addHandlerToMap(handlerMap, source, handlerObj); addHandlerToMap(handlerMap, target, handlerObj); - const isEnterLeave = isEnterLeaveMode(genericConfig); - if (isEnterLeave) { - const enter = genericConfig.enter ?? []; - const leaveEvents = genericConfig.leave ?? []; + if (enterLeave) { + const enter = genericConfig.enter!; + const leaveEvents = genericConfig.leave!; enter.forEach((eventType) => { if (eventType === 'focusin') { source.tabIndex = 0; - addListener(source, eventType, focusListener as EventListener, { once }); - return; } - addListener(source, eventType, handler as EventListener, { passive: true, once }); + addListener(source, eventType, { passive: true, once }); }); const addLeaveListeners = isTransition ? (options as StateParams).method === 'toggle' @@ -158,23 +181,17 @@ function addEventTriggerHandler( if (addLeaveListeners) { leaveEvents.forEach((eventType) => { if (eventType === 'focusout') { - addListener(source, eventType, focusListener as EventListener, { once }); + addListener(source, eventType, { once }); return; } - addListener(source, eventType, handler as EventListener, { passive: true }); + addListener(source, eventType, { passive: true }); }); } } else { const events = genericConfig.toggle ?? []; events.forEach((eventType) => { - const opts = { passive: true, once }; - if (eventType === 'click') { - addListener(source, 'click', clickListener as EventListener, opts); - } else if (eventType === 'keydown') { - addListener(source, 'keydown', keydownListener as EventListener, { once }); - } else { - addListener(source, eventType, handler as EventListener, opts); - } + const opts = eventType === 'keydown' ? { once } : { passive: true, once }; + addListener(source, eventType, opts); }); } } From 6e8827e859678923c3815c876433f9886225414f Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Thu, 26 Feb 2026 11:34:40 +0200 Subject: [PATCH 7/8] more CR changes --- packages/interact/src/handlers/constants.ts | 9 ++++++++ .../interact/src/handlers/effectHandlers.ts | 22 ++++--------------- .../interact/src/handlers/eventTrigger.ts | 16 ++++++++------ packages/interact/src/handlers/index.ts | 2 +- 4 files changed, 23 insertions(+), 26 deletions(-) create mode 100644 packages/interact/src/handlers/constants.ts diff --git a/packages/interact/src/handlers/constants.ts b/packages/interact/src/handlers/constants.ts new file mode 100644 index 00000000..3f3cebb9 --- /dev/null +++ b/packages/interact/src/handlers/constants.ts @@ -0,0 +1,9 @@ +export const EVENT_TRIGGER_PRESETS = { + click: ['click'] as const, + activate: ['click', 'keydown'] as const, + hover: { enter: ['mouseenter'], leave: ['mouseleave'] } as const, + interest: { + enter: ['mouseenter', 'focusin'], + leave: ['mouseleave', 'focusout'], + } as const, +} as const; diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index 796de8c2..dfcde84d 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -12,16 +12,6 @@ import type { import { effectToAnimationOptions } from './utilities'; import fastdom from 'fastdom'; -export const EVENT_TRIGGER_PRESETS = { - click: ['click'] as const, - activate: ['click', 'keydown'] as const, - hover: { enter: ['mouseenter'], leave: ['mouseleave'] } as const, - interest: { - enter: ['mouseenter', 'focusin'], - leave: ['mouseleave', 'focusout'], - } as const, -} as const; - export function createTimeEffectHandler( element: HTMLElement, effect: TimeEffect & EffectBase, @@ -43,15 +33,13 @@ export function createTimeEffectHandler( let initialPlay = true; const type = options.type || 'alternate'; - const enterEvents = enterLeave?.enter ?? []; - const leaveEvents = enterLeave?.leave ?? []; return (event: Event) => { if (selectorCondition && !element.matches(selectorCondition)) return; const isToggle = !enterLeave; - const isEnter = enterEvents.length > 0 && enterEvents.includes(event.type); - const isLeave = leaveEvents.length > 0 && leaveEvents.includes(event.type); + const isEnter = enterLeave?.enter?.includes(event.type); + const isLeave = enterLeave?.leave?.includes(event.type); if (isEnter || isToggle) { if (type === 'alternate' || type === 'state') { @@ -112,8 +100,6 @@ export function createTransitionHandler( const shouldSetStateOnElement = !!listContainer; const method = options.method || 'toggle'; const isToggle = method === 'toggle'; - const enterEvents = enterLeave?.enter ?? []; - const leaveEvents = enterLeave?.leave ?? []; return (event: Event) => { if (selectorCondition && !element.matches(selectorCondition)) return; @@ -124,8 +110,8 @@ export function createTransitionHandler( ) as HTMLElement | null) : undefined; // undefined when no listContainer so controller delegates to element.toggleEffect const isToggleMode = !enterLeave; - const isEnter = enterEvents.length > 0 && enterEvents.includes(event.type); - const isLeave = leaveEvents.length > 0 && leaveEvents.includes(event.type); + const isEnter = enterLeave?.enter?.includes(event.type); + const isLeave = enterLeave?.leave?.includes(event.type); if (isToggleMode) { targetController.toggleEffect(effectId, method, item); diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 967f6446..304370ec 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -62,7 +62,7 @@ function getListenerForEventType( handler: EventHandler, ): EventListener { const factory = LISTENER_FACTORY_BY_EVENT_TYPE[event]; - return factory ? factory(source, handler) : createKeydownListener(handler); + return factory ? factory(source, handler) : (e: Event) => handler(e); } type GenericEventConfig = { @@ -95,7 +95,7 @@ function createGenericEventConfig(config: EventTriggerConfig): GenericEventConfi } function isEnterLeaveMode(genericConfig: GenericEventConfig): boolean { - return (genericConfig.enter?.length ?? 0) > 0 || (genericConfig.leave?.length ?? 0) > 0; + return !!(genericConfig.enter?.length || genericConfig.leave?.length); } function getEnterLeaveConfig( @@ -148,18 +148,17 @@ function addEventTriggerHandler( } const resolvedHandler = handler; + const controller = new AbortController(); const listeners: { element: HTMLElement; event: string; fn: EventListener }[] = []; function addListener(element: HTMLElement, event: string, options?: AddEventListenerOptions) { const fn = getListenerForEventType(event, source, resolvedHandler); - element.addEventListener(event, fn, options); + element.addEventListener(event, fn, { ...options, signal: controller.signal }); listeners.push({ element, event, fn }); } const cleanup = () => { - listeners.forEach(({ element, event, fn }) => { - element.removeEventListener(event, fn); - }); + controller.abort(); }; const handlerObj = { source, target, cleanup }; @@ -169,6 +168,7 @@ function addEventTriggerHandler( if (enterLeave) { const enter = genericConfig.enter!; const leaveEvents = genericConfig.leave!; + enter.forEach((eventType) => { if (eventType === 'focusin') { source.tabIndex = 0; @@ -178,6 +178,7 @@ function addEventTriggerHandler( const addLeaveListeners = isTransition ? (options as StateParams).method === 'toggle' : (options as PointerTriggerParams).type !== 'once'; + if (addLeaveListeners) { leaveEvents.forEach((eventType) => { if (eventType === 'focusout') { @@ -190,7 +191,8 @@ function addEventTriggerHandler( } else { const events = genericConfig.toggle ?? []; events.forEach((eventType) => { - const opts = eventType === 'keydown' ? { once } : { passive: true, once }; + const passive = eventType !== 'keydown'; + const opts = { once, passive }; addListener(source, eventType, opts); }); } diff --git a/packages/interact/src/handlers/index.ts b/packages/interact/src/handlers/index.ts index b314a8ad..ac0712bb 100644 --- a/packages/interact/src/handlers/index.ts +++ b/packages/interact/src/handlers/index.ts @@ -11,7 +11,7 @@ import viewProgressHandler from './viewProgress'; import pointerMoveHandler from './pointerMove'; import animationEndHandler from './animationEnd'; import eventTrigger from './eventTrigger'; -import { EVENT_TRIGGER_PRESETS } from './effectHandlers'; +import { EVENT_TRIGGER_PRESETS } from './constants'; const a11yTriggerOverrides = { click: EVENT_TRIGGER_PRESETS.activate, From 2a457ef5a8bd9b170ab7d8a2caeeba7e4e342d24 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Sun, 1 Mar 2026 12:01:55 +0200 Subject: [PATCH 8/8] removed listeners array and fix tests to check signal.abort Made-with: Cursor --- .../interact/src/handlers/eventTrigger.ts | 2 -- packages/interact/test/mini.spec.ts | 34 ++++++++++++------ packages/interact/test/web.spec.ts | 35 +++++++++++++------ 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 304370ec..0f402714 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -149,12 +149,10 @@ function addEventTriggerHandler( const resolvedHandler = handler; const controller = new AbortController(); - const listeners: { element: HTMLElement; event: string; fn: EventListener }[] = []; function addListener(element: HTMLElement, event: string, options?: AddEventListenerOptions) { const fn = getListenerForEventType(event, source, resolvedHandler); element.addEventListener(event, fn, { ...options, signal: controller.signal }); - listeners.push({ element, event, fn }); } const cleanup = () => { diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 62a96fa8..3330c424 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -1276,13 +1276,20 @@ describe('interact (mini)', () => { element = document.createElement('div'); element.dataset.interactKey = key; - const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); + const addEventListenerSpy = vi.spyOn(element, 'addEventListener'); add(element, key); + + const signals = addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter(Boolean) as AbortSignal[]; + + expect(signals.length).toBe(2); + expect(signals.filter((signal) => signal.aborted)).toHaveLength(0); + remove(key); - expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect(signals.filter((signal) => signal.aborted)).toHaveLength(2); }); it('should do nothing if key does not exist', () => { @@ -2292,15 +2299,18 @@ describe('interact (mini)', () => { Interact.create(config); const triggerButton = sourceElement.querySelector('.trigger-button') as HTMLElement; - const removeEventListenerSpy = vi.spyOn(triggerButton, 'removeEventListener'); + const addEventListenerSpy = vi.spyOn(triggerButton, 'addEventListener'); add(sourceElement, 'cleanup-source'); add(targetElement, 'cleanup-target'); remove('cleanup-source'); - // Should remove event listeners from the selected element - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect( + addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter((signal): signal is AbortSignal => !!signal?.aborted), + ).toHaveLength(1); }); }); }); @@ -2762,7 +2772,6 @@ describe('interact (mini)', () => { }); const addEventListenerSpy = vi.spyOn(testElement, 'addEventListener'); - const removeEventListenerSpy = vi.spyOn(testElement, 'removeEventListener'); add(testElement, 'responsive-element'); @@ -2778,9 +2787,12 @@ describe('interact (mini)', () => { expect.any(Object), ); - // Clear spies for next assertions + const clickSignals = addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter(Boolean) as AbortSignal[]; + + // Clear spy for next assertions addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); // Now simulate media query change to mobile const desktopMql = mockMQLs.get('(min-width: 1024px)'); @@ -2798,8 +2810,8 @@ describe('interact (mini)', () => { const mockEvent = { matches: false, media: '(min-width: 1024px)' } as MediaQueryListEvent; listenerEntry!.handler(mockEvent); - // The old click handler should be removed (this will fail due to isConnected check) - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + // The old click signals should be aborted + expect(clickSignals.filter((signal) => signal.aborted)).toHaveLength(1); // The new hover handler should be added expect(addEventListenerSpy).toHaveBeenCalledWith( diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index b5730af5..45f4acb5 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -1278,13 +1278,20 @@ describe('interact (web)', () => { const div = document.createElement('div'); element.append(div); - const removeEventListenerSpy = vi.spyOn(div, 'removeEventListener'); + const addEventListenerSpy = vi.spyOn(div, 'addEventListener'); add(element, key); + + const signals = addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter(Boolean) as AbortSignal[]; + + expect(signals.length).toBe(2); + expect(signals.filter((signal) => signal.aborted)).toHaveLength(0); + remove(key); - expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect(signals.filter((signal) => signal.aborted)).toHaveLength(2); }); it('should do nothing if key does not exist', () => { @@ -2351,15 +2358,18 @@ describe('interact (web)', () => { Interact.create(config, { useCutsomElement: true }); const triggerButton = sourceElement.querySelector('.trigger-button') as HTMLElement; - const removeEventListenerSpy = vi.spyOn(triggerButton, 'removeEventListener'); + const addEventListenerSpy = vi.spyOn(triggerButton, 'addEventListener'); add(sourceElement, 'cleanup-source'); add(targetElement, 'cleanup-target'); remove('cleanup-source'); - // Should remove event listeners from the selected element - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + expect( + addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter((signal): signal is AbortSignal => !!signal?.aborted), + ).toHaveLength(1); }); }); }); @@ -2647,7 +2657,6 @@ describe('interact (web)', () => { }); const addEventListenerSpy = vi.spyOn(div, 'addEventListener'); - const removeEventListenerSpy = vi.spyOn(div, 'removeEventListener'); add(testElement, 'responsive-element'); @@ -2663,9 +2672,13 @@ describe('interact (web)', () => { expect.any(Object), ); - // Clear spies for next assertions + // Capture signals from the initial (desktop/click) listeners + const clickSignals = addEventListenerSpy.mock.calls + .map((call) => (call[2] as AddEventListenerOptions)?.signal) + .filter(Boolean) as AbortSignal[]; + + // Clear spy for next assertions addEventListenerSpy.mockClear(); - removeEventListenerSpy.mockClear(); // Now simulate media query change to mobile const desktopMql = mockMQLs.get('(min-width: 1024px)'); @@ -2683,8 +2696,8 @@ describe('interact (web)', () => { const mockEvent = { matches: false, media: '(min-width: 1024px)' } as MediaQueryListEvent; listenerEntry!.handler(mockEvent); - // The old click handler should be removed (this will fail due to isConnected check) - expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + // The old click signals should be aborted + expect(clickSignals.filter((signal) => signal.aborted)).toHaveLength(1); // The new hover handler should be added expect(addEventListenerSpy).toHaveBeenCalledWith(