# `events` — `InputEventEmitter` _(no submodules)_ ## Purpose Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the dispatcher in its own module keeps the lifecycle FSM in `BaseController` focused on normalisation logic, and lets downstream consumers (e.g. the merger in Chunk C) reuse the same disposable-listener contract without depending on the base class. ## Features - Three typed channels: `onInput(key, cb)`, `onStateChange(cb)`, `onCombo(cb)`. Every registration returns a `Disposer` that removes exactly the registered listener without affecting siblings. - Per-key bucket storage with `Set` semantics: O(1) add, O(1) remove, iteration in insertion order. - Last-value tracking — each `emitInput` passes the previous value to the listener so consumers can compute deltas without storing state. - Strict-mode opt-in: `new InputEventEmitter({ strict: true })` throws `ControllerError(DESTROYED_INSTANCE)` when an `on*` method is called after `destroy()`. Default mode silently returns a no-op disposer. - Zod-validated options boundary — invalid construction throws `ControllerError(INVALID_OPTIONS)` with the Zod issue array in `context.issues`. - Idempotent `destroy()` — calling twice is safe. ## Usage: Basic ```ts import { InputEventEmitter } from "@suaveplan/controller"; const emitter = new InputEventEmitter(); const off = emitter.onInput("up", (input, value, prev) => { console.log(input, value, prev); }); emitter.emitInput("up", 255); off(); emitter.destroy(); ``` ## Usage: Advanced ```ts import { InputEventEmitter, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller"; const emitter = new InputEventEmitter({ strict: true }); emitter.destroy(); try { emitter.onInput("a", () => undefined); } catch (err) { if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) { console.warn("emitter no longer accepting listeners"); } } ``` The strict mode is what `BaseController` opts into to enforce its own destroyed-state guarantees; default mode preserves the upstream emitter shape used by older fan-out callers that expect post-destroy registrations to be silent. ## API Reference - `new InputEventEmitter({ strict? }: InputEventEmitterOptions)` - `onInput(input, cb): Disposer` - `onStateChange(cb): Disposer` - `onCombo(cb): Disposer` - `emitInput(input, value): void` - `emitStateChange(state, oldState?): void` - `emitCombo(combo): void` - `clear(): void` - `destroy(): void` - `destroyed: boolean` (getter) ## Implementation Notes The per-key bucket is allocated lazily so an emitter with no listeners on a given key pays no map cost. Disposers capture the `callback` and `input` by closure rather than indexing into the listener registry, so multiple `onInput("up", cb)` calls with the same callback are coalesced by the `Set` (no double-fire), and disposing one call removes the entry cleanly. `emitInput` records the new value into `lastInputValues` even when there is no registered listener, so a late subscriber's first event sees the correct `prevValue`.