diff --git a/.cursor/rules/cleanCode.mdc b/.cursor/rules/cleanCode.mdc new file mode 100644 index 00000000..00675121 --- /dev/null +++ b/.cursor/rules/cleanCode.mdc @@ -0,0 +1,180 @@ +--- +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 + +- **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/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 new file mode 100644 index 00000000..dfcde84d --- /dev/null +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -0,0 +1,127 @@ +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 function createTimeEffectHandler( + element: HTMLElement, + effect: TimeEffect & EffectBase, + options: PointerTriggerParams, + reducedMotion: boolean = false, + selectorCondition?: string, + enterLeave?: EventTriggerConfigEnterLeave, +): ((event: Event) => 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'; + + return (event: Event) => { + if (selectorCondition && !element.matches(selectorCondition)) return; + + const isToggle = !enterLeave; + const isEnter = enterLeave?.enter?.includes(event.type); + const isLeave = enterLeave?.leave?.includes(event.type); + + 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); + delete element.dataset.interactEnter; + if (animation.isCSS) { + animation.onFinish(() => { + fastdom.mutate(() => { + element.dataset.interactEnter = 'done'; + }); + }); + } + animation.play(); + } + return; + } + + 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(); + } + } + }; +} + +export function createTransitionHandler( + element: HTMLElement, + targetController: IInteractionController, + { + effectId, + listContainer, + listItemSelector, + }: TransitionEffect & EffectBase & { effectId: string }, + options: StateParams, + selectorCondition?: string, + enterLeave?: EventTriggerConfigEnterLeave, +): (event: Event) => void { + const shouldSetStateOnElement = !!listContainer; + const method = options.method || 'toggle'; + const isToggle = method === 'toggle'; + + return (event: Event) => { + 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 = enterLeave?.enter?.includes(event.type); + const isLeave = enterLeave?.leave?.includes(event.type); + + if (isToggleMode) { + targetController.toggleEffect(effectId, method, 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 new file mode 100644 index 00000000..0f402714 --- /dev/null +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -0,0 +1,206 @@ +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 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) : (e: Event) => handler(e); +} + +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 || genericConfig.leave?.length); +} + +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 }: InteractOptions, +) { + const genericConfig = createGenericEventConfig(options.eventConfig); + const isTransition = + (effect as TransitionEffect).transition || (effect as TransitionEffect).transitionProperties; + + const enterLeave = getEnterLeaveConfig(genericConfig); + + let handler: EventHandler | 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 resolvedHandler = handler; + const controller = new AbortController(); + + function addListener(element: HTMLElement, event: string, options?: AddEventListenerOptions) { + const fn = getListenerForEventType(event, source, resolvedHandler); + element.addEventListener(event, fn, { ...options, signal: controller.signal }); + } + + const cleanup = () => { + controller.abort(); + }; + + const handlerObj = { source, target, cleanup }; + addHandlerToMap(handlerMap, source, handlerObj); + addHandlerToMap(handlerMap, target, handlerObj); + + if (enterLeave) { + const enter = genericConfig.enter!; + const leaveEvents = genericConfig.leave!; + + enter.forEach((eventType) => { + if (eventType === 'focusin') { + source.tabIndex = 0; + } + addListener(source, eventType, { passive: true, once }); + }); + const addLeaveListeners = isTransition + ? (options as StateParams).method === 'toggle' + : (options as PointerTriggerParams).type !== 'once'; + + if (addLeaveListeners) { + leaveEvents.forEach((eventType) => { + if (eventType === 'focusout') { + addListener(source, eventType, { once }); + return; + } + addListener(source, eventType, { passive: true }); + }); + } + } else { + const events = genericConfig.toggle ?? []; + events.forEach((eventType) => { + const passive = eventType !== 'keydown'; + const opts = { once, passive }; + addListener(source, eventType, 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..ac0712bb 100644 --- a/packages/interact/src/handlers/index.ts +++ b/packages/interact/src/handlers/index.ts @@ -8,36 +8,53 @@ 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 './constants'; -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; +const a11yTriggerOverrides = { + click: EVENT_TRIGGER_PRESETS.activate, + hover: EVENT_TRIGGER_PRESETS.interest, +} as const; + +function withEventTriggerConfig(presetKey: keyof typeof EVENT_TRIGGER_PRESETS) { + const preset = EVENT_TRIGGER_PRESETS[presetKey]; + return ( + source: HTMLElement, + target: HTMLElement, + effect: Effect, + options: StateParams | PointerTriggerParams, + interactOptions?: InteractOptions, + ) => { + const eventConfig = + interactOptions?.allowA11yTriggers && presetKey in a11yTriggerOverrides + ? a11yTriggerOverrides[presetKey as keyof typeof a11yTriggerOverrides] + : preset; + eventTrigger.add(source, target, effect, { ...options, eventConfig }, interactOptions ?? {}); + }; } export default { viewEnter: viewEnterHandler, - hover: hoverHandler, - click: clickHandler, + hover: { + add: withEventTriggerConfig('hover'), + remove: eventTrigger.remove, + }, + click: { + add: withEventTriggerConfig('click'), + remove: eventTrigger.remove, + }, pageVisible: viewEnterHandler, animationEnd: animationEndHandler, viewProgress: viewProgressHandler, pointerMove: pointerMoveHandler, - activate: withA11y(clickHandler), - interest: withA11y(hoverHandler), + activate: { + add: withEventTriggerConfig('activate'), + remove: eventTrigger.remove, + }, + interest: { + add: withEventTriggerConfig('interest'), + remove: eventTrigger.remove, + }, } as TriggerHandlerMap; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 7bffa724..c442145f 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -34,6 +34,15 @@ 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 +55,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..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(