From 4f8fb360281ff242483669c6099bcd25015da16e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 4 Aug 2026 21:22:47 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20keyboard:=20dispatch=20key=20events?= =?UTF-8?q?=20to=20focused=20elements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/keyboard.test.ts | 118 +++++++++++++++++++++++++++++++ packages/ui/src/keyboard.ts | 43 +++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/ui/src/keyboard.test.ts create mode 100644 packages/ui/src/keyboard.ts diff --git a/packages/ui/src/keyboard.test.ts b/packages/ui/src/keyboard.test.ts new file mode 100644 index 0000000..5be73f8 --- /dev/null +++ b/packages/ui/src/keyboard.test.ts @@ -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 }); + }, + })); +} diff --git a/packages/ui/src/keyboard.ts b/packages/ui/src/keyboard.ts new file mode 100644 index 0000000..bdec2e4 --- /dev/null +++ b/packages/ui/src/keyboard.ts @@ -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) + ); +}