From eb42602f65a054c4128b85b37870b25121138493 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Sun, 15 Feb 2026 08:27:27 +0200 Subject: [PATCH 1/3] Event Triggers Support - Plan --- executionPlan.md | 176 +++++++++++++++++++++++++++++++++++++++++++++++ requirement.md | 53 ++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 executionPlan.md create mode 100644 requirement.md diff --git a/executionPlan.md b/executionPlan.md new file mode 100644 index 0000000..6100fce --- /dev/null +++ b/executionPlan.md @@ -0,0 +1,176 @@ +# Execution Plan: Event Triggers Support + +This plan implements the feature described in [requirement.md](./requirement.md): a generic **eventTrigger** implementation that replaces the current separate click and hover handlers, with configurable event bindings for click, hover, activate, and interest. + +Each task is small, leaves the codebase in a passing state, and is validated by existing or new tests. + +--- + +## Phase 0: Baseline and shared types + +### Task 0.1 — Add types for event-trigger configuration + +**Goal:** Introduce types that describe how an event trigger binds to DOM events, without changing behavior. + +**Scope:** +- In `packages/interact/src/types.ts` (or a dedicated types file), add: + - **Toggle-style config:** list of event names that all invoke the same “toggle” action (e.g. `['click']` or `['click', 'keydown']`). Handler does not branch on `event.type`. + - **Enter/leave-style config:** two lists — “enter” events (e.g. `mouseenter`, `focusin`) and “leave” events (e.g. `mouseleave`, `focusout`). Handler branches on `event.type` to decide play vs pause/reverse. + +**Deliverable:** Types only (e.g. `EventTriggerKind`, `EventTriggerConfig`). No handler changes. + +**Validation:** +- `pnpm build` (or project build) succeeds. +- Existing test suite passes (e.g. `pnpm test` in `packages/interact`). + +--- + +### Task 0.2 — Extract shared effect logic used by click and hover + +**Goal:** Remove duplication between click and hover so the future eventTrigger can reuse one implementation. + +**Scope:** +- Both handlers use: + - `createTimeEffectHandler(element, effect, options, …)` (play/reverse/pause based on `type`). + - `createTransitionHandler(element, targetController, effect, options, …)` (toggleEffect add/remove). +- Extract these into a shared module (e.g. `packages/interact/src/handlers/effectHandlers.ts` or `eventTriggerEffectHandlers.ts`). +- Have `click.ts` and `hover.ts` import and use the shared functions. Signatures and behavior unchanged. + +**Deliverable:** New shared module; click and hover refactored to use it only. + +**Validation:** +- All existing tests for click and hover pass (`web.spec.ts`, `mini.spec.ts`: click, hover, activate, interest, and a11y trigger tests). +- No change in which events are bound or how effects run. + +--- + +## Phase 1: Generic eventTrigger handler + +### Task 1.1 — Implement eventTrigger handler (toggle mode only) + +**Goal:** New handler that binds a single “toggle” action to a configurable list of event types; handler does not use `event.type`. + +**Scope:** +- New file: `packages/interact/src/handlers/eventTrigger.ts`. +- Export `{ add, remove }` with the same interface as other trigger handlers (`source`, `target`, `effect`, `options`, `interactOptions`). +- Accept an **options-level or parameter** that supplies the event config (for now, hardcode or pass a toggle config: e.g. events `['click']`). +- Reuse the shared effect logic from Task 0.2 to create the inner callback. Attach one listener per configured event type; same callback for all. Cleanup removes all listeners. +- Do **not** yet wire this from `handlers/index.ts` for `click`; keep existing click handler as-is. + +**Deliverable:** `eventTrigger.ts` that can drive a toggle-style interaction from `['click']` (or a single config). + +**Validation:** +- New unit test(s): add interaction with eventTrigger (toggle, `['click']`), dispatch click on source, assert effect runs (e.g. animation play/reverse or toggleEffect called). Remove element, assert listeners removed (no duplicate invocations after remove). +- Existing click/hover tests still pass (click still uses old handler). + +--- + +### Task 1.2 — Implement eventTrigger enter/leave mode + +**Goal:** eventTrigger supports a second mode: “enter/leave” with two sets of events; handler branches on `event.type` to apply play vs pause/reverse. + +**Scope:** +- Extend eventTrigger to accept an enter/leave config: `enter: ['mouseenter', 'focusin']`, `leave: ['mouseleave', 'focusout']`. +- Reuse shared enter/leave effect logic (same behavior as current hover time-effect and transition handlers). Handler receives `MouseEvent | FocusEvent` and branches on `event.type === 'mouseenter' | 'focusin'` vs `'mouseleave' | 'focusout'`. +- Attach one listener per event in `enter` and `leave`; all share the same handler. Cleanup removes all. +- Still do not wire hover in index to eventTrigger. + +**Deliverable:** eventTrigger supports both toggle and enter/leave configs. + +**Validation:** +- New unit test(s): add interaction with eventTrigger (enter/leave, mouseenter/mouseleave), dispatch mouseenter then mouseleave, assert play then reverse (or equivalent). Optionally focusin/focusout. +- Existing tests unchanged. + +--- + +### Task 1.3 — Wire click and activate to eventTrigger + +**Goal:** Use eventTrigger for click and activate so the handler is event-type-agnostic for toggle; activate adds keydown (Enter/Space). + +**Scope:** +- In `handlers/index.ts`: + - **click:** Invoke eventTrigger with toggle config `['click']` (no keydown). + - **activate:** Invoke eventTrigger with toggle config `['click', 'keydown']` and a11y behavior (Enter/Space trigger the same action; ensure no double-fire on Enter as in current tests). Preserve `allowA11yTriggers` behavior (tabIndex, etc.) via interactOptions. +- Remove or bypass the old click handler for these two trigger types. If needed, keep `click.ts` as a thin wrapper that calls eventTrigger with click config until cleanup phase. + +**Deliverable:** click and activate are implemented via eventTrigger; handler does not branch on `event.type` for which action to run. + +**Validation:** +- All existing click and activate tests pass (`describe('click')`, `describe('activate trigger')`, “should not double-invoke handler when Enter triggers both keydown and click”, etc.) in both `web.spec.ts` and `mini.spec.ts`. + +--- + +### Task 1.4 — Wire hover and interest to eventTrigger + +**Goal:** Use eventTrigger for hover and interest with enter/leave event mapping; handler branches on `event.type` for enter vs leave. + +**Scope:** +- **hover:** eventTrigger with enter/leave config `enter: ['mouseenter']`, `leave: ['mouseleave']`; no focus events. +- **interest:** eventTrigger with `enter: ['mouseenter', 'focusin']`, `leave: ['mouseleave', 'focusout']`, and a11y options (tabIndex, etc.) when `allowA11yTriggers` is true. Preserve focusin/focusout containment check (only trigger when focus moves in/out of source). +- Remove or bypass the old hover handler for these trigger types. + +**Deliverable:** hover and interest implemented via eventTrigger; enter/leave behavior unchanged. + +**Validation:** +- All existing hover and interest tests pass (`describe('hover')`, `describe('interest trigger')`, a11y hover tests) in both test files. + +--- + +## Phase 2: Cleanup and documentation + +### Task 2.1 — Remove legacy click and hover handler implementations + +**Goal:** Single implementation path: eventTrigger only for the four event-style triggers. + +**Scope:** +- If `click.ts` and `hover.ts` still exist as thin wrappers, either remove them and have `handlers/index.ts` call eventTrigger with the appropriate config for `click`, `hover`, `activate`, `interest`, or keep minimal wrappers that only pass config to eventTrigger. +- Ensure no duplicated logic remains in click.ts/hover.ts (all logic in shared effect module + eventTrigger). +- Fix any inconsistency (e.g. hover time-effect uses `dataset.motionEnter` in one place vs `dataset.interactEnter` elsewhere) to a single convention. + +**Deliverable:** No duplicate handler logic; eventTrigger is the single implementation for click, hover, activate, interest. + +**Validation:** +- Full test suite passes. +- Grep for `createTimeEffectHandler` / `createTransitionHandler` shows usage only in the shared module and eventTrigger (or files that delegate to it). + +--- + +### Task 2.2 — Document eventTrigger and align with requirement + +**Goal:** Code and docs reflect the design in requirement.md and support future extensibility. + +**Scope:** +- Add a short comment in `eventTrigger.ts` (or handlers/README if present) describing: + - Toggle vs enter/leave modes. + - That click/activate are toggle (handler ignores `event.type`); hover/interest are enter/leave (handler checks `event.type`). + - That new event types can be added by extending config (future: arbitrary event names, stateless triggers). +- Optionally add a “Trigger implementation” section to the main README or requirement.md referencing this plan and eventTrigger. + +**Deliverable:** Comments/docs updated; requirement.md goals clearly met (generic eventTrigger, event-type-agnostic for click/activate, event-type-based for hover/interest). + +**Validation:** +- Review requirement.md checklist: all design points covered by implementation. + +--- + +## Summary table + +| Task | Description | Main validation | +|--------|--------------------------------------------------|------------------------------------------| +| 0.1 | Add event-trigger config types | Build + tests pass | +| 0.2 | Extract shared effect logic (click/hover) | Click/hover/activate/interest tests pass | +| 1.1 | eventTrigger toggle mode | New unit tests for toggle | +| 1.2 | eventTrigger enter/leave mode | New unit tests for enter/leave | +| 1.3 | Wire click & activate to eventTrigger | All click/activate tests pass | +| 1.4 | Wire hover & interest to eventTrigger | All hover/interest tests pass | +| 2.1 | Remove legacy click/hover implementation | Full suite + no duplicate logic | +| 2.2 | Document eventTrigger and requirement alignment | Requirement checklist met | + +--- + +## Out of scope (per requirement) + +- **viewEnter / pageVisible:** No change (Timeline Trigger). +- **animationEnd:** Deferred to next phase. +- **viewProgress / pointerMove:** No change (scrub/timeline). +- **Stateless triggers / granular spec actions:** Future work; this plan only establishes the generic eventTrigger and current stateful behavior. diff --git a/requirement.md b/requirement.md new file mode 100644 index 0000000..377dce3 --- /dev/null +++ b/requirement.md @@ -0,0 +1,53 @@ +# Event Triggers Support + +Change current trigger handlers implementation to a generic event trigger implementation of stateless and stateful triggers, according to the CSS specification. + +## Context + +Currently we support 6 triggers with 4 matching handlers implementing them. + +**Triggers:** + +- `viewEnter` +- `animationEnd` +- `click` +- `hover` +- `activate` (alias of click) +- `interest` (alias of hover) + +Handlers match the first 4 above. + +Since `viewEnter` actually implements a **Timeline Trigger**, we don't need to touch it for now. + +We deviate from the spec of stateful/stateless because the behavior types we use are different from the trigger actions that the spec defines: + +- The **behavior** in the Config (in params' type) contains 2 actions: **playing** and **pausing/stopping/reversing** +- The **actions** in the spec are singular. + +## Implementation + +We can generally say that currently we only have **stateful triggers**, since all the behaviors we have (the type param) require triggers to be stateful. + +- **click** implements a stateful trigger — in CSS it would be written as `click click` with a matching action for each. +- **hover** implements a stateful trigger — it's an alias of `mouseenter` and `mouseleave`, with an action for each. +- **activate** is an alias of click and also binds specific keypress (Enter and Space). +- **interest** is an alias of hover and also binds `focusin` and `focusout`. + +The difference between activate and interest: + +- **activate** ⇒ `click click` and `keypress keypress` +- **interest** ⇒ `mouseenter mouseleave` and `focusin focusout` + +## Design + +1. Change **click** and **hover** into a generic **eventTrigger** implementation. +2. **click** and **activate** need to work so the handler doesn't care about `event.type`. +3. **hover** and **interest** need to be mapped to: + - `mouseenter` / `mouseleave` + - `focusin` / `focusout` +4. Implementation must check `event.type` in the handler to determine which action to apply. +5. This allows later: + - Specifying any event type with this eventTrigger handler. + - Granular actions and **stateless triggers**. + +**Note:** What to do with `animationEnd` is not yet decided; leaving it for the next phase. From 86a0d672e827653a3545bae84e8bc3b3a5225987 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Mon, 16 Feb 2026 09:30:52 +0200 Subject: [PATCH 2/3] update execution Plan --- executionPlan.md | 98 +++++++++++++++++++++++++++++++----------------- requirement.md | 69 ++++++++++++++++------------------ 2 files changed, 97 insertions(+), 70 deletions(-) diff --git a/executionPlan.md b/executionPlan.md index 6100fce..911d7bc 100644 --- a/executionPlan.md +++ b/executionPlan.md @@ -3,7 +3,6 @@ This plan implements the feature described in [requirement.md](./requirement.md): a generic **eventTrigger** implementation that replaces the current separate click and hover handlers, with configurable event bindings for click, hover, activate, and interest. Each task is small, leaves the codebase in a passing state, and is validated by existing or new tests. - --- ## Phase 0: Baseline and shared types @@ -16,32 +15,38 @@ Each task is small, leaves the codebase in a passing state, and is validated by - In `packages/interact/src/types.ts` (or a dedicated types file), add: - **Toggle-style config:** list of event names that all invoke the same “toggle” action (e.g. `['click']` or `['click', 'keydown']`). Handler does not branch on `event.type`. - **Enter/leave-style config:** two lists — “enter” events (e.g. `mouseenter`, `focusin`) and “leave” events (e.g. `mouseleave`, `focusout`). Handler branches on `event.type` to decide play vs pause/reverse. +- The config should support **custom event names** (callers can pass event names that are not predefined). **Deliverable:** Types only (e.g. `EventTriggerKind`, `EventTriggerConfig`). No handler changes. **Validation:** -- `pnpm build` (or project build) succeeds. -- Existing test suite passes (e.g. `pnpm test` in `packages/interact`). +- `yarn build` (or project build) succeeds. +- Existing test suite passes (e.g. `yarn test` or `yarn workspace @wix/interact test` in `packages/interact`). --- -### Task 0.2 — Extract shared effect logic used by click and hover +### Task 0.2 — Extract shared effect logic and add event-config constants map -**Goal:** Remove duplication between click and hover so the future eventTrigger can reuse one implementation. +**Goal:** Remove duplication between click and hover and centralize event configuration so the future eventTrigger can reuse one implementation and one source of truth for trigger → event config. **Scope:** - Both handlers use: - `createTimeEffectHandler(element, effect, options, …)` (play/reverse/pause based on `type`). - `createTransitionHandler(element, targetController, effect, options, …)` (toggleEffect add/remove). -- Extract these into a shared module (e.g. `packages/interact/src/handlers/effectHandlers.ts` or `eventTriggerEffectHandlers.ts`). +- Extract these into a **single shared module** (e.g. `packages/interact/src/handlers/effectHandlers.ts` or `eventTriggerEffectHandlers.ts`). +- **Do not** introduce a separate file per event type. Instead, define a **constants map** that maps trigger names (or config keys) to the shared event configuration: + - Preset entries for `click`, `hover`, `activate`, `interest` (see Trigger → config table below). + - Allow **custom event types**: the eventTrigger handler must accept either a key into this map or an **inline config object** (custom event names) so new triggers do not require new files. - Have `click.ts` and `hover.ts` import and use the shared functions. Signatures and behavior unchanged. -**Deliverable:** New shared module; click and hover refactored to use it only. +**Deliverable:** New shared module with effect logic; constants map for event config (presets + support for custom config); click and hover refactored to use shared logic only. **Validation:** - All existing tests for click and hover pass (`web.spec.ts`, `mini.spec.ts`: click, hover, activate, interest, and a11y trigger tests). - No change in which events are bound or how effects run. +**Future (out of scope for first commit):** Tests should eventually cover **other event types** beyond the current four triggers (e.g. custom event names, or other DOM events used via the same eventTrigger). The design (constants map + config object) must allow adding and testing them later without adding new handler files. + --- ## Phase 1: Generic eventTrigger handler @@ -53,11 +58,11 @@ Each task is small, leaves the codebase in a passing state, and is validated by **Scope:** - New file: `packages/interact/src/handlers/eventTrigger.ts`. - Export `{ add, remove }` with the same interface as other trigger handlers (`source`, `target`, `effect`, `options`, `interactOptions`). -- Accept an **options-level or parameter** that supplies the event config (for now, hardcode or pass a toggle config: e.g. events `['click']`). +- Accept an **options-level or parameter** that supplies the event config. Config must support the **union type** (see Task 1.4): for toggle mode, accept `string` (e.g. `'click'`) or `string[]` (e.g. `['click', 'keydown']`). Resolve from constants map by trigger key or accept inline config. - Reuse the shared effect logic from Task 0.2 to create the inner callback. Attach one listener per configured event type; same callback for all. Cleanup removes all listeners. - Do **not** yet wire this from `handlers/index.ts` for `click`; keep existing click handler as-is. -**Deliverable:** `eventTrigger.ts` that can drive a toggle-style interaction from `['click']` (or a single config). +**Deliverable:** `eventTrigger.ts` that can drive a toggle-style interaction from `'click'` or `['click']` (or a single config). **Validation:** - New unit test(s): add interaction with eventTrigger (toggle, `['click']`), dispatch click on source, assert effect runs (e.g. animation play/reverse or toggleEffect called). Remove element, assert listeners removed (no duplicate invocations after remove). @@ -70,12 +75,12 @@ Each task is small, leaves the codebase in a passing state, and is validated by **Goal:** eventTrigger supports a second mode: “enter/leave” with two sets of events; handler branches on `event.type` to apply play vs pause/reverse. **Scope:** -- Extend eventTrigger to accept an enter/leave config: `enter: ['mouseenter', 'focusin']`, `leave: ['mouseleave', 'focusout']`. -- Reuse shared enter/leave effect logic (same behavior as current hover time-effect and transition handlers). Handler receives `MouseEvent | FocusEvent` and branches on `event.type === 'mouseenter' | 'focusin'` vs `'mouseleave' | 'focusout'`. +- Extend eventTrigger to accept enter/leave config as **object** `{ enter?: string[], leave?: string[] }` (e.g. `enter: ['mouseenter', 'focusin']`, `leave: ['mouseleave', 'focusout']`). Config shape is part of the union type (Task 1.4). +- Reuse shared enter/leave effect logic (same behavior as current hover time-effect and transition handlers). Handler receives `MouseEvent | FocusEvent` and branches on `event.type` (e.g. `'mouseenter' | 'focusin'` vs `'mouseleave' | 'focusout'`). - Attach one listener per event in `enter` and `leave`; all share the same handler. Cleanup removes all. - Still do not wire hover in index to eventTrigger. -**Deliverable:** eventTrigger supports both toggle and enter/leave configs. +**Deliverable:** eventTrigger supports both toggle (string | string[]) and enter/leave (object with enter/leave arrays) configs. **Validation:** - New unit test(s): add interaction with eventTrigger (enter/leave, mouseenter/mouseleave), dispatch mouseenter then mouseleave, assert play then reverse (or equivalent). Optionally focusin/focusout. @@ -88,9 +93,9 @@ Each task is small, leaves the codebase in a passing state, and is validated by **Goal:** Use eventTrigger for click and activate so the handler is event-type-agnostic for toggle; activate adds keydown (Enter/Space). **Scope:** -- In `handlers/index.ts`: - - **click:** Invoke eventTrigger with toggle config `['click']` (no keydown). - - **activate:** Invoke eventTrigger with toggle config `['click', 'keydown']` and a11y behavior (Enter/Space trigger the same action; ensure no double-fire on Enter as in current tests). Preserve `allowA11yTriggers` behavior (tabIndex, etc.) via interactOptions. +- In `handlers/index.ts` (or via constants map): + - **click:** Invoke eventTrigger with toggle config `'click'` or `['click']` (no keydown). + - **activate:** Invoke eventTrigger with toggle config `['click', 'keydown']` and a11y behavior (keydown restricted to Enter or Space; same action as click; ensure no double-fire on Enter as in current tests). Preserve `allowA11yTriggers` behavior (tabIndex, etc.) via interactOptions. - Remove or bypass the old click handler for these two trigger types. If needed, keep `click.ts` as a thin wrapper that calls eventTrigger with click config until cleanup phase. **Deliverable:** click and activate are implemented via eventTrigger; handler does not branch on `event.type` for which action to run. @@ -100,16 +105,25 @@ Each task is small, leaves the codebase in a passing state, and is validated by --- -### Task 1.4 — Wire hover and interest to eventTrigger +### Task 1.4 — Wire hover and interest to eventTrigger; define config union type -**Goal:** Use eventTrigger for hover and interest with enter/leave event mapping; handler branches on `event.type` for enter vs leave. +**Goal:** Use eventTrigger for hover and interest with enter/leave event mapping; handler branches on `event.type` for enter vs leave. Standardize the trigger config type so eventTrigger accepts a single, flexible shape. **Scope:** -- **hover:** eventTrigger with enter/leave config `enter: ['mouseenter']`, `leave: ['mouseleave']`; no focus events. -- **interest:** eventTrigger with `enter: ['mouseenter', 'focusin']`, `leave: ['mouseleave', 'focusout']`, and a11y options (tabIndex, etc.) when `allowA11yTriggers` is true. Preserve focusin/focusout containment check (only trigger when focus moves in/out of source). +- **Trigger config type** — eventTrigger must accept a config expressed as one of: + - **Enter/leave object:** `{ enter?: string[], leave?: string[] }` + e.g. `{ enter: ['mouseenter', 'focusin'], leave: ['mouseleave', 'focusout'] }` + - **Toggle as array:** `string[]` + e.g. `['click', 'keydown']` + - **Toggle as single event:** `string` + e.g. `'click'` + - Normalize internally (e.g. string → array for toggle; detect enter/leave vs toggle from shape). +- **hover:** eventTrigger with enter/leave config `{ enter: ['mouseenter'], leave: ['mouseleave'] }`; no focus events. +- **interest:** eventTrigger with `{ enter: ['mouseenter', 'focusin'], leave: ['mouseleave', 'focusout'] }`, and a11y options (tabIndex, etc.) when `allowA11yTriggers` is true. Preserve focusin/focusout containment check (only trigger when focus moves in/out of source). - Remove or bypass the old hover handler for these trigger types. +- Preset configs for hover and interest should come from the constants map (Task 0.2). -**Deliverable:** hover and interest implemented via eventTrigger; enter/leave behavior unchanged. +**Deliverable:** hover and interest implemented via eventTrigger; config union type defined and used; enter/leave behavior unchanged. **Validation:** - All existing hover and interest tests pass (`describe('hover')`, `describe('interest trigger')`, a11y hover tests) in both test files. @@ -123,33 +137,48 @@ Each task is small, leaves the codebase in a passing state, and is validated by **Goal:** Single implementation path: eventTrigger only for the four event-style triggers. **Scope:** -- If `click.ts` and `hover.ts` still exist as thin wrappers, either remove them and have `handlers/index.ts` call eventTrigger with the appropriate config for `click`, `hover`, `activate`, `interest`, or keep minimal wrappers that only pass config to eventTrigger. +- If `click.ts` and `hover.ts` still exist as thin wrappers, either remove them and have `handlers/index.ts` call eventTrigger with the appropriate config from the constants map for `click`, `hover`, `activate`, `interest`, or keep minimal wrappers that only pass config to eventTrigger. - Ensure no duplicated logic remains in click.ts/hover.ts (all logic in shared effect module + eventTrigger). - Fix any inconsistency (e.g. hover time-effect uses `dataset.motionEnter` in one place vs `dataset.interactEnter` elsewhere) to a single convention. -**Deliverable:** No duplicate handler logic; eventTrigger is the single implementation for click, hover, activate, interest. +**Deliverable:** No duplicate handler logic; eventTrigger is the single implementation for click, hover, activate, interest; constants map is the single source of preset configs. **Validation:** -- Full test suite passes. +- Full test suite passes (use `yarn test` or `yarn workspace @wix/interact test`). - Grep for `createTimeEffectHandler` / `createTransitionHandler` shows usage only in the shared module and eventTrigger (or files that delegate to it). --- -### Task 2.2 — Document eventTrigger and align with requirement +### Task 2.2 — Document eventTrigger, trigger → config mapping, and update rules -**Goal:** Code and docs reflect the design in requirement.md and support future extensibility. +**Goal:** Code and docs reflect the design in requirement.md; implementers and rules know the exact config per trigger; rules in `packages/interact/rules/` stay in sync with the implementation. **Scope:** - Add a short comment in `eventTrigger.ts` (or handlers/README if present) describing: - - Toggle vs enter/leave modes. + - Toggle vs enter/leave modes and the config union type (`string | string[] | { enter?, leave? }`). - That click/activate are toggle (handler ignores `event.type`); hover/interest are enter/leave (handler checks `event.type`). - - That new event types can be added by extending config (future: arbitrary event names, stateless triggers). -- Optionally add a “Trigger implementation” section to the main README or requirement.md referencing this plan and eventTrigger. + - That new event types can be added via the constants map or inline config (future: arbitrary event names, stateless triggers). +- **Trigger → config mapping (single source of truth):** Document the exact runtime config for each trigger (in code comments and/or a small table in plan/docs): + + | Trigger | Config (runtime) | + |-----------|------------------| + | **click** | `'click'` or `['click']` (toggle) | + | **activate** | `['click', 'keydown']` (toggle; keydown only Enter or Space) | + | **hover** | `{ enter: ['mouseenter'], leave: ['mouseleave'] }` | + | **interest** | `{ enter: ['mouseenter', 'focusin'], leave: ['mouseleave', 'focusout'] }` | + + Ensure this mapping matches the constants map so the “actual config” is the single source of truth. +- **Update rules:** Update the rule files in **`packages/interact/rules/`** (e.g. `click.md`, `hover.md`, `integration.md`, `full-lean.md`, and any other rule files that describe triggers) so that they: + - Describe trigger configuration as the **config object** (string | string[] | `{ enter?, leave? }`) rather than only “trigger: 'click'” or “trigger: 'hover'”. + - Document that **click** and **activate** use the same handler with different configs (toggle: `'click'` vs `['click', 'keydown']` with Enter/Space). + - Document that **hover** and **interest** use the same handler with different configs (enter/leave: mouse only vs mouse + focusin/focusout). + - Align rule examples and variable placeholders with the actual config shapes (e.g. custom events as string arrays or enter/leave arrays). -**Deliverable:** Comments/docs updated; requirement.md goals clearly met (generic eventTrigger, event-type-agnostic for click/activate, event-type-based for hover/interest). +**Deliverable:** Comments/docs updated; trigger → config table documented; requirement.md goals clearly met; rules in `packages/interact/rules/` updated to describe eventTrigger config and the trigger-to-config mapping. **Validation:** - Review requirement.md checklist: all design points covered by implementation. +- Rules reference the config union and the trigger → config mapping where relevant. --- @@ -157,14 +186,14 @@ Each task is small, leaves the codebase in a passing state, and is validated by | Task | Description | Main validation | |--------|--------------------------------------------------|------------------------------------------| -| 0.1 | Add event-trigger config types | Build + tests pass | -| 0.2 | Extract shared effect logic (click/hover) | Click/hover/activate/interest tests pass | +| 0.1 | Add event-trigger config types | `yarn build` + tests pass | +| 0.2 | Extract shared effect logic + constants map | Click/hover/activate/interest tests pass | | 1.1 | eventTrigger toggle mode | New unit tests for toggle | | 1.2 | eventTrigger enter/leave mode | New unit tests for enter/leave | | 1.3 | Wire click & activate to eventTrigger | All click/activate tests pass | -| 1.4 | Wire hover & interest to eventTrigger | All hover/interest tests pass | -| 2.1 | Remove legacy click/hover implementation | Full suite + no duplicate logic | -| 2.2 | Document eventTrigger and requirement alignment | Requirement checklist met | +| 1.4 | Wire hover & interest; config union type | All hover/interest tests pass | +| 2.1 | Remove legacy click/hover implementation | Full suite + no duplicate logic | +| 2.2 | Document eventTrigger, mapping, update rules | Requirement checklist + rules updated | --- @@ -174,3 +203,4 @@ Each task is small, leaves the codebase in a passing state, and is validated by - **animationEnd:** Deferred to next phase. - **viewProgress / pointerMove:** No change (scrub/timeline). - **Stateless triggers / granular spec actions:** Future work; this plan only establishes the generic eventTrigger and current stateful behavior. +- **Tests for other event types:** Out of scope for the first commit; design (constants map + config) must allow adding and testing them later. diff --git a/requirement.md b/requirement.md index 377dce3..64c9b73 100644 --- a/requirement.md +++ b/requirement.md @@ -1,53 +1,50 @@ -# Event Triggers Support +# `Event Triggers` Support -Change current trigger handlers implementation to a generic event trigger implementation of stateless and stateful triggers, according to the CSS specification. +## Change current trigger handlers implementation to a generic event trigger implementation of stateless and stateful triggers, according to [the CSS specification](https://drafts.csswg.org/css-animations-2/#event-triggers). -## Context +# Context -Currently we support 6 triggers with 4 matching handlers implementing them. +Currently we support 6 triggers with 4 matching handlers implementing them. +The following triggers: -**Triggers:** +* `viewEnter` +* `animationEnd` +* `click` +* `hover` +* `activate` (alias of `click`) +* `interest` (alias of `hover`) -- `viewEnter` -- `animationEnd` -- `click` -- `hover` -- `activate` (alias of click) -- `interest` (alias of hover) +With handlers matching the first 4 above. -Handlers match the first 4 above. - -Since `viewEnter` actually implements a **Timeline Trigger**, we don't need to touch it for now. +Since `viewEnter` actually implements a [`Timeline Trigger`](https://drafts.csswg.org/css-animations-2/#timeline-triggers), we don’t need to touch it for now. We deviate from the spec of stateful/stateless because the behavior types we use are different from the trigger actions that the spec defines: -- The **behavior** in the Config (in params' type) contains 2 actions: **playing** and **pausing/stopping/reversing** -- The **actions** in the spec are singular. +* The behavior in the Config (in params’ `type`) contain 2 actions: playing and pausing/stopping/reversing +* The actions in the spec are singular. + +# Implementation -## Implementation +We can generally say that currently we only have ***stateful*** triggers since all the behaviors we have (the `type` param) require triggers to be stateful. -We can generally say that currently we only have **stateful triggers**, since all the behaviors we have (the type param) require triggers to be stateful. +So that: -- **click** implements a stateful trigger — in CSS it would be written as `click click` with a matching action for each. -- **hover** implements a stateful trigger — it's an alias of `mouseenter` and `mouseleave`, with an action for each. -- **activate** is an alias of click and also binds specific keypress (Enter and Space). -- **interest** is an alias of hover and also binds `focusin` and `focusout`. +* `click` implements a ***stateful*** trigger \- currently in CSS it would be written as `click click` with a matching action for each. +* `hover` implements a ***stateful*** trigger (also) \- since it’s actually an alias of `mouseenter` and `mouseleave`, with an action for each. +* `activate` is alias of `click` and also binds specific `keypress` (with Enter and Space) +* `interest` is alias of `hover` and also binds `focusin` and `focusout` -The difference between activate and interest: +The difference between `activate` and `interest` is that they would be specified as: -- **activate** ⇒ `click click` and `keypress keypress` -- **interest** ⇒ `mouseenter mouseleave` and `focusin focusout` +* `activate` \=\> `click click` and `keypress keypress` +* `interest` \=\> `mouseenter mouseleave` and `focusin focusout` ## Design -1. Change **click** and **hover** into a generic **eventTrigger** implementation. -2. **click** and **activate** need to work so the handler doesn't care about `event.type`. -3. **hover** and **interest** need to be mapped to: - - `mouseenter` / `mouseleave` - - `focusin` / `focusout` -4. Implementation must check `event.type` in the handler to determine which action to apply. -5. This allows later: - - Specifying any event type with this eventTrigger handler. - - Granular actions and **stateless triggers**. - -**Note:** What to do with `animationEnd` is not yet decided; leaving it for the next phase. +* We need to see if we can change `click` and `hover` into a generic `eventTrigger` implementation +* `click` and `activate` need to work like handler doesn’t care about the `event.type` +* `hover` and `interest` need to mapped to `mouseenter mouseleave` and `focusin focusout` +* Implementation needs to check for the `event.type` in the handler in order to understand which action to apply +* In this way we can later allow specifying any event type with this `eventTrigger` handler +* And later we could also allow granular actions, so that we could also have ***stateless*** triggers +* Not sure yet what we do with `animationEnd`. Leaving it for next phase. \ No newline at end of file From 02f6b193f3b4633c670a475684b40d35ffe3fc30 Mon Sep 17 00:00:00 2001 From: Michael Beeri Date: Mon, 16 Feb 2026 16:37:19 +0200 Subject: [PATCH 3/3] execution plan phase 0 --- packages/interact/src/handlers/click.ts | 101 +------------ .../interact/src/handlers/effectHandlers.ts | 142 ++++++++++++++++++ packages/interact/src/handlers/hover.ts | 122 ++------------- packages/interact/src/types.ts | 9 ++ 4 files changed, 166 insertions(+), 208 deletions(-) create mode 100644 packages/interact/src/handlers/effectHandlers.ts diff --git a/packages/interact/src/handlers/click.ts b/packages/interact/src/handlers/click.ts index 40c0dfb..ad743c1 100644 --- a/packages/interact/src/handlers/click.ts +++ b/packages/interact/src/handlers/click.ts @@ -1,5 +1,3 @@ -import { getAnimation } from '@wix/motion'; -import type { AnimationGroup } from '@wix/motion'; import type { TimeEffect, TransitionEffect, @@ -7,106 +5,13 @@ import type { HandlerObjectMap, PointerTriggerParams, EffectBase, - IInteractionController, InteractOptions, } from '../types'; -import { - effectToAnimationOptions, - addHandlerToMap, - removeElementFromHandlerMap, -} from './utilities'; -import fastdom from 'fastdom'; +import { addHandlerToMap, removeElementFromHandlerMap } from './utilities'; +import { createTimeEffectHandler, createTransitionHandler } from './effectHandlers'; 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, @@ -127,6 +32,7 @@ function addClickHandler( effect as TransitionEffect & EffectBase & { effectId: string }, options as StateParams, selectorCondition, + undefined, ); } else { handler = createTimeEffectHandler( @@ -135,6 +41,7 @@ function addClickHandler( options as PointerTriggerParams, reducedMotion, selectorCondition, + undefined, ); once = (options as PointerTriggerParams).type === 'once'; } diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts new file mode 100644 index 0000000..48754ab --- /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/hover.ts b/packages/interact/src/handlers/hover.ts index f7eec11..7607761 100644 --- a/packages/interact/src/handlers/hover.ts +++ b/packages/interact/src/handlers/hover.ts @@ -1,5 +1,3 @@ -import type { AnimationGroup } from '@wix/motion'; -import { getAnimation } from '@wix/motion'; import type { TimeEffect, TransitionEffect, @@ -7,121 +5,17 @@ import type { HandlerObjectMap, PointerTriggerParams, EffectBase, - IInteractionController, InteractOptions, } from '../types'; +import { addHandlerToMap, removeElementFromHandlerMap } from './utilities'; import { - effectToAnimationOptions, - addHandlerToMap, - removeElementFromHandlerMap, -} from './utilities'; -import fastdom from 'fastdom'; + createTimeEffectHandler, + createTransitionHandler, + EVENT_TRIGGER_PRESETS, +} from './effectHandlers'; 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, @@ -129,6 +23,10 @@ function addHoverHandler( options: StateParams | PointerTriggerParams = {}, { reducedMotion, targetController, selectorCondition, allowA11yTriggers }: InteractOptions, ) { + const enterLeaveConfig = allowA11yTriggers + ? EVENT_TRIGGER_PRESETS.interest + : EVENT_TRIGGER_PRESETS.hover; + let handler: ((event: MouseEvent | FocusEvent) => void) | null; let isStateTrigger = false; let once = false; @@ -143,6 +41,7 @@ function addHoverHandler( effect as TransitionEffect & EffectBase & { effectId: string }, options as StateParams, selectorCondition, + enterLeaveConfig, ); isStateTrigger = true; } else { @@ -152,6 +51,7 @@ function addHoverHandler( options as PointerTriggerParams, reducedMotion, selectorCondition, + enterLeaveConfig, ); once = (options as PointerTriggerParams).type === 'once'; } diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 7bffa72..069987c 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 = string[]; +export type EventTriggerConfigEnterLeave = { + enter?: readonly string[]; + leave?: readonly string[]; +}; + +export type EventTriggerConfig = EventTriggerConfigToggle | EventTriggerConfigEnterLeave; + export type ViewEnterType = 'once' | 'repeat' | 'alternate' | 'state'; export type TransitionMethod = 'add' | 'remove' | 'toggle' | 'clear';