From f8a59bafcd43227bf1708d6ec06963392df0d3bb Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 5 Aug 2026 10:19:46 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20events:=20deliver=20emitted=20event?= =?UTF-8?q?s=20to=20host=20listeners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/package.json | 4 + packages/ui/src/event-listener.test.ts | 181 +++++++++++++++++++++++++ packages/ui/src/events.ts | 23 ++++ packages/ui/src/host.ts | 68 +++++++++- packages/ui/tsconfig.json | 3 +- 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/event-listener.test.ts create mode 100644 packages/ui/src/events.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index a58976e..ac5e511 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,10 @@ "types": "./dist/elements.d.mts", "import": "./dist/elements.mjs" }, + "./events": { + "types": "./dist/events.d.mts", + "import": "./dist/events.mjs" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/ui/src/event-listener.test.ts b/packages/ui/src/event-listener.test.ts new file mode 100644 index 0000000..7068aef --- /dev/null +++ b/packages/ui/src/event-listener.test.ts @@ -0,0 +1,181 @@ +import type { HostEvent } from '@clack/ui/events'; +import { describe, expect, expectTypeOf, test } from 'vitest'; +import type { Node } from './core.ts'; +import type { HostElement } from './elements.ts'; +import { createHost, type Host, HostApi } from './host.ts'; +import type { AnyHostEvent } from './events.ts'; + +interface ProbeEvent extends HostEvent<'@test/probe'> { + readonly value: number; +} + +declare module '@clack/ui/events' { + interface HostEvents { + '@test/focus': HostEvent<'@test/focus'>; + '@test/input': HostEvent<'@test/input'>; + '@test/probe': ProbeEvent; + } +} + +describe('event listeners', () => { + test('delivers an event to a listener registered before attachment', () => { + const host = createHost(); + const target = host.createElement('probe'); + const events: EventSnapshot[] = []; + host.addEventListener(target, '@test/input', (event: HostEvent) => { + events.push(snapshot(event, target)); + }); + + expect(target.node).toBeNull(); + host.insertBefore(host.element, target); + + emit(target.node!, { type: '@test/input', value: 'hello' }); + + expect(events).toEqual([ + { + type: '@test/input', + value: 'hello', + target: true, + }, + ]); + }); + + test('scopes listeners by element and event type', () => { + const host = createHost(); + const first = attach(host, 'first'); + const second = attach(host, 'second'); + const calls: string[] = []; + host.addEventListener(first, '@test/input', () => calls.push('first input')); + host.addEventListener(first, '@test/focus', () => calls.push('first focus')); + host.addEventListener(second, '@test/input', () => calls.push('second input')); + + emit(first.node!, { type: '@test/input' }); + + expect(calls).toEqual(['first input']); + }); + + test('invokes distinct listeners in registration order without duplicating one listener', () => { + const host = createHost(); + const target = attach(host, 'probe'); + const calls: string[] = []; + const first = (): void => { + calls.push('first'); + }; + const second = (): void => { + calls.push('second'); + }; + host.addEventListener(target, '@test/input', first); + host.addEventListener(target, '@test/input', first); + host.addEventListener(target, '@test/input', second); + + emit(target.node!, { type: '@test/input' }); + + expect(calls).toEqual(['first', 'second']); + }); + + test('defers listeners added during an emission until the next event', () => { + const host = createHost(); + const target = attach(host, 'probe'); + const calls: string[] = []; + const later = (): void => { + calls.push('later'); + }; + host.addEventListener(target, '@test/input', () => { + calls.push('first'); + host.addEventListener(target, '@test/input', later); + }); + + emit(target.node!, { type: '@test/input' }); + + expect(calls).toEqual(['first']); + + emit(target.node!, { type: '@test/input' }); + + expect(calls).toEqual(['first', 'first', 'later']); + }); + + test('infers listener events from the host event map', () => { + const host = createHost(); + const target = attach(host, 'probe'); + let value: number | undefined; + host.addEventListener(target, '@test/probe', (event) => { + expectTypeOf(event).toEqualTypeOf(); + value = event.value; + }); + + emit(target.node!, { type: '@test/probe', value: 42 }); + + expect(value).toBe(42); + }); + + test('removes a listener by element, event type, and identity', () => { + const host = createHost(); + const target = attach(host, 'probe'); + const calls: string[] = []; + const listener = (event: HostEvent): void => { + calls.push(event.type); + }; + host.addEventListener(target, '@test/input', listener); + host.addEventListener(target, '@test/focus', listener); + + host.removeEventListener(target, '@test/input', listener); + host.removeEventListener(target, '@test/input', listener); + emit(target.node!, { type: '@test/input' }); + emit(target.node!, { type: '@test/focus' }); + + expect(calls).toEqual(['@test/focus']); + }); + + test('preserves listeners when an element is detached and reattached', () => { + const host = createHost(); + const first = attach(host, 'first'); + const second = attach(host, 'second'); + const target = host.createElement('probe'); + let calls = 0; + host.addEventListener(target, '@test/input', () => calls++); + host.insertBefore(first, target); + const firstNode = target.node; + + emit(target.node!, { type: '@test/input' }); + host.insertBefore(second, target); + emit(target.node!, { type: '@test/input' }); + + expect(target.node).not.toBe(firstNode); + expect(calls).toBe(2); + }); + + test('propagates listener errors to the emitter', () => { + const host = createHost(); + const target = attach(host, 'probe'); + const reason = new Error('listener failed'); + host.addEventListener(target, '@test/input', () => { + throw reason; + }); + + expect(() => emit(target.node!, { type: '@test/input' })).toThrowError(reason); + }); +}); + +interface EventSnapshot { + type: string; + value: unknown; + target: boolean; +} + +function attach(host: Host, name: string): HostElement { + const element = host.createElement(name); + host.insertBefore(host.element, element); + return element; +} + +function snapshot(event: HostEvent, target: HostElement): EventSnapshot { + return { + type: event.type, + value: 'value' in event ? event.value : undefined, + target: event.target === target, + }; +} + +function emit(node: Node, event: AnyHostEvent): void { + HostApi.at(node).emit(event); +} diff --git a/packages/ui/src/events.ts b/packages/ui/src/events.ts new file mode 100644 index 0000000..eb3f948 --- /dev/null +++ b/packages/ui/src/events.ts @@ -0,0 +1,23 @@ +import type { HostElement } from './elements.ts'; + +/** + * Open interface that is extended with new event types + */ +export interface HostEvents {} + +export interface HostEvent { + type: T; + target: HostElement; +} + +export type HostEventType = keyof HostEvents; + +export type AnyHostEvent = HostEvents[HostEventType]; + +export type WithoutTarget = T extends unknown ? Omit : never; + +export type HostEventData = WithoutTarget; + +export interface HostEventListener { + (event: HostEvents[T]): void; +} diff --git a/packages/ui/src/host.ts b/packages/ui/src/host.ts index d337ccd..a55f2bb 100644 --- a/packages/ui/src/host.ts +++ b/packages/ui/src/host.ts @@ -1,7 +1,6 @@ // oxlint-disable max-params import { text } from '@bomb.sh/tty'; import { type Node, lifecycle, LifecycleApi } from './core.ts'; -import { Api } from './core/api.ts'; import { getElement, setElement, @@ -10,6 +9,8 @@ import { type HostLiteral, } from './elements.ts'; import { LayoutApi } from './layout.ts'; +import type { HostEventType, HostEventListener, HostEventData } from './events.ts'; +import { Api } from './core/api.ts'; export interface Host { root: Node; @@ -20,11 +21,22 @@ export interface Host { removeChild(parent: HostElement, child: HostElementChild): void; setProperty(element: HostElement, name: string, value: unknown): void; setText(text: HostLiteral, content: string): void; + addEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void; + removeEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void; } export class HostApi extends Api implements Host { root: Node; element: HostElement; + listeners = new WeakMap>>>(); constructor(root: Node) { super(root); @@ -136,6 +148,60 @@ export class HostApi extends Api implements Host { getHost(): Host { return HostApi.at(this.root); } + + addEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void { + const map = this.listeners; + let types = map.get(element); + if (!types) { + map.set(element, (types = new Map())); + } + let listeners = types.get(type); + if (!listeners) { + types.set(type, (listeners = new Set())); + } + listeners.add(listener as HostEventListener); + } + + removeEventListener( + element: HostElement, + type: T, + listener: HostEventListener, + ): void { + const map = this.listeners; + const types = map.get(element); + if (types) { + const listeners = types.get(type); + if (listeners) { + listeners.delete(listener as HostEventListener); + if (listeners.size === 0) { + types.delete(type); + } + } + if (types.size === 0) { + map.delete(element); + } + } + } + + emit(data: HostEventData): void { + const target = getElement(this.node); + const event = { ...data, target }; + + const types = this.listeners.get(target); + if (types) { + const listeners = types.get(event.type); + if (listeners) { + const active = [...listeners]; + for (const listener of active) { + listener(event); + } + } + } + } } export function createHost(): Host { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 39b2a63..4dc9d44 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@bomb.sh/tools/tsconfig.json", "compilerOptions": { "paths": { - "@clack/ui/elements": ["./src/elements.ts"] + "@clack/ui/elements": ["./src/elements.ts"], + "@clack/ui/events": ["./src/events.ts"] }, "types": ["node"] }