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
118 changes: 118 additions & 0 deletions packages/ui/src/keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { KeyDown, KeyEvent } from '@bomb.sh/tty';
import type { Node } from './core.ts';
import { describe, expect, test } from 'vitest';
import { DispatchApi } from './dispatch.ts';
import { FocusApi } from './focus.ts';
import { createHost, type Host } from './host.ts';
import { KeyboardApi } from './keyboard.ts';

describe('keyboard dispatch', () => {
test('routes keydown, keyup, and keyrepeat to the currently focused node', () => {
const host = setupKeyboardHost();
const first = createFocusable(host);
const second = createFocusable(host);
const firstEvents: RecordedKeyEvent[] = [];
const secondEvents: RecordedKeyEvent[] = [];
recordKeyboard(first, firstEvents);
recordKeyboard(second, secondEvents);
setFocus(second);

const events: KeyEvent[] = [
{ type: 'keydown', key: 'a', code: 'a', text: 'a' },
{ type: 'keyup', key: 'a', code: 'a' },
{ type: 'keyrepeat', key: 'a', code: 'a', text: 'a' },
];
for (const event of events) {
expect(DispatchApi.at(host.root).dispatch(event).ok).toBe(true);
}

expect(firstEvents).toEqual([]);
expect(secondEvents.map(({ node }) => node)).toEqual([second, second, second]);
for (const [index, event] of events.entries()) {
expect(secondEvents[index]?.event).toBe(event);
}
});

test('returns ordinary dispatch success without a handled payload', () => {
const host = setupKeyboardHost();
const target = createFocusable(host);
recordKeyboard(target, []);

expect(DispatchApi.at(host.root).dispatch(keydown('a', 'a'))).toEqual({ ok: true });
});

test('delegates non-key and malformed key-like events unchanged', () => {
const host = setupKeyboardHost();
const delegated: unknown[] = [];
DispatchApi.decorate(host.root, () => ({
dispatch(event) {
delegated.push(event);
return super.dispatch(event);
},
}));

DispatchApi.at(host.root).dispatch(keydown('a', 'a'));
const others: unknown[] = [
{ type: 'resize', width: 80, height: 24 },
{ type: 'keydown', code: 'a' },
{ type: 'keydown', key: 'a' },
{ type: 'keypress', key: 'a', code: 'a' },
];
for (const event of others) {
DispatchApi.at(host.root).dispatch(event);
}

expect(delegated).toEqual(others);
});
});

interface RecordedKeyEvent {
node: Node;
event: KeyEvent;
}

function setupKeyboardHost(): Host {
const host = createHost();
DispatchApi.init(host.root);
FocusApi.init(host.root);
KeyboardApi.init(host.root);
return host;
}

function createFocusable(host: Host): Node {
const element = host.createElement('box');
host.insertBefore(host.element, element);
setFocusable(element.node!);
return element.node!;
}

function setFocus(node: Node): void {
FocusApi.at(node).setFocus(node);
}

function setFocusable(node: Node): void {
FocusApi.at(node).setFocusable();
}

function keydown(code: KeyDown['code'], text?: string): KeyDown {
return {
type: 'keydown',
key: text ?? code,
code,
...(text === undefined ? {} : { text }),
};
}

function recordKeyboard(node: Node, events: RecordedKeyEvent[]): void {
KeyboardApi.decorate(node, () => ({
keydown(event) {
events.push({ node: this.node, event });
},
keyup(event) {
events.push({ node: this.node, event });
},
keyrepeat(event) {
events.push({ node: this.node, event });
},
}));
}
43 changes: 43 additions & 0 deletions packages/ui/src/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// oxlint-disable no-unused-vars
import type { KeyDown, KeyEvent, KeyRepeat, KeyUp } from '@bomb.sh/tty';
import { DispatchApi } from './dispatch.ts';
import { FocusApi } from './focus.ts';
import { Api } from './core/api.ts';
import type { Node } from './core/node.ts';

export class KeyboardApi extends Api {
constructor(node: Node) {
super(node);

// re-dispatch key events to the current focus
DispatchApi.decorate(node, () => ({
dispatch(event) {
if (isKeyEvent(event)) {
const focus = FocusApi.at(this.node).getFocus();
const api = KeyboardApi.at(focus);

api[event.type](event as KeyDown & KeyUp & KeyRepeat);

return { ok: true };
} else {
return super.dispatch(event);
}
},
}));
}

keydown(_event: KeyDown): void {}
keyup(_event: KeyUp): void {}
keyrepeat(_event: KeyRepeat): void {}
}

function isKeyEvent(value: unknown): value is KeyEvent {
const x = value as KeyEvent;

return (
!!x &&
typeof x.key === 'string' &&
typeof x.code === 'string' &&
['keyup', 'keydown', 'keyrepeat'].includes(x.type)
);
}
Loading