Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
181 changes: 181 additions & 0 deletions packages/ui/src/event-listener.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeEvent>();
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);
}
23 changes: 23 additions & 0 deletions packages/ui/src/events.ts
Original file line number Diff line number Diff line change
@@ -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<T extends HostEventType = HostEventType> {
type: T;
target: HostElement;
}

export type HostEventType = keyof HostEvents;

export type AnyHostEvent = HostEvents[HostEventType];

export type WithoutTarget<T> = T extends unknown ? Omit<T, 'target'> : never;

export type HostEventData = WithoutTarget<AnyHostEvent>;

export interface HostEventListener<T extends HostEventType> {
(event: HostEvents[T]): void;
}
68 changes: 67 additions & 1 deletion packages/ui/src/host.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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<T extends HostEventType>(
element: HostElement,
type: T,
listener: HostEventListener<T>,
): void;
removeEventListener<T extends HostEventType>(
element: HostElement,
type: T,
listener: HostEventListener<T>,
): void;
}

export class HostApi extends Api implements Host {
root: Node;
element: HostElement;
listeners = new WeakMap<HostElement, Map<string, Set<HostEventListener<HostEventType>>>>();

constructor(root: Node) {
super(root);
Expand Down Expand Up @@ -136,6 +148,60 @@ export class HostApi extends Api implements Host {
getHost(): Host {
return HostApi.at(this.root);
}

addEventListener<T extends HostEventType>(
element: HostElement,
type: T,
listener: HostEventListener<T>,
): 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<HostEventType>);
}

removeEventListener<T extends HostEventType>(
element: HostElement,
type: T,
listener: HostEventListener<T>,
): void {
const map = this.listeners;
const types = map.get(element);
if (types) {
const listeners = types.get(type);
if (listeners) {
listeners.delete(listener as HostEventListener<HostEventType>);
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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Expand Down
Loading