91 lines
3.1 KiB
Markdown
91 lines
3.1 KiB
Markdown
# `events` — `InputEventEmitter`
|
|
|
|
<!-- begin:auto-docs -->
|
|
_(no submodules)_
|
|
<!-- end:auto-docs -->
|
|
|
|
## 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`.
|