From baeda0c387b483da9cf4ac7596863b074ff0fde4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 4 Aug 2026 18:26:10 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20input:=20add=20semantic=20text=20ed?= =?UTF-8?q?iting=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/elements/input.test.ts | 529 +++++++++++++++++++++++++ packages/ui/src/elements/input.ts | 212 ++++++++++ 2 files changed, 741 insertions(+) create mode 100644 packages/ui/src/elements/input.test.ts create mode 100644 packages/ui/src/elements/input.ts diff --git a/packages/ui/src/elements/input.test.ts b/packages/ui/src/elements/input.test.ts new file mode 100644 index 0000000..d5c501b --- /dev/null +++ b/packages/ui/src/elements/input.test.ts @@ -0,0 +1,529 @@ +// oxlint-disable max-params +import type { KeyDown, KeyRepeat } from '@bomb.sh/tty'; +import { type Node } from '../core.ts'; +import type { HostEvents } from '@clack/ui/events'; +import { describe, expect, expectTypeOf, test } from 'vitest'; +import { DispatchApi } from '../dispatch.ts'; +import type { HostElement } from '../elements.ts'; +import { FocusApi } from '../focus.ts'; +import { createHost, type Host } from '../host.ts'; +import { KeyboardApi } from '../keyboard.ts'; +import { InputApi, type InputProps, useInputElement } from './input.ts'; + +describe('input', () => { + describe('InputApi', () => { + test('starts empty with the caret at zero', () => { + const { input } = setup(); + + expectState(input, '', 0); + }); + + describe('insert', () => { + test('inserts text at the caret and advances the caret', () => { + const { input, node } = setup(); + + insert(node, 'h'); + + expectState(input, 'h', 1); + }); + + test('inserts a run of characters from left to right', () => { + const { input, node } = setup(); + + typeText(node, 'cat'); + + expectState(input, 'cat', 3); + }); + + test('inserts text in the middle of the value', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveBack(node); + + insert(node, 'x'); + + expectState(input, 'caxt', 3); + }); + + test('advances by every code point in a multi-character insertion', () => { + const { input, node } = setup(); + typeText(node, 'ab'); + moveBack(node); + + insert(node, '😀x'); + + expectState(input, 'a😀xb', 3); + }); + + test('does nothing when inserting an empty string', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveBack(node); + + insert(node, ''); + + expectState(input, 'cat', 2); + }); + + test('removes line breaks from inserted text', () => { + const { input, node } = setup(); + + insert(node, 'a\nb\r\nc\r'); + + expectState(input, 'abc', 3); + }); + }); + + describe('deleteBackward', () => { + test('removes the code point before the caret', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveMin(node); + moveForward(node); + + deleteBackward(node); + + expectState(input, 'at', 0); + }); + + test('removes the final code point when the caret is at the end', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + + deleteBackward(node); + + expectState(input, 'ca', 2); + }); + + test('does nothing when the caret is at the start', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveMin(node); + + deleteBackward(node); + + expectState(input, 'cat', 0); + }); + + test('removes an entire multi-code-unit code point', () => { + const { input, node } = setup(); + typeText(node, 'a😀'); + + deleteBackward(node); + + expectState(input, 'a', 1); + }); + }); + + describe('deleteForward', () => { + test('removes the code point after the caret', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveMin(node); + moveForward(node); + + deleteForward(node); + + expectState(input, 'ct', 1); + }); + + test('does nothing when the caret is at the end', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + + deleteForward(node); + + expectState(input, 'cat', 3); + }); + + test('removes an entire multi-code-unit code point', () => { + const { input, node } = setup(); + typeText(node, 'a😀b'); + moveMin(node); + moveForward(node); + + deleteForward(node); + + expectState(input, 'ab', 1); + }); + }); + + describe('caret movement', () => { + test('moves one code point toward the start', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + + moveBack(node); + + expectState(input, 'cat', 2); + }); + + test('moves one code point toward the end', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveMin(node); + + moveForward(node); + + expectState(input, 'cat', 1); + }); + + test('does not move before the start', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + moveMin(node); + + moveBack(node); + + expectState(input, 'cat', 0); + }); + + test('does not move past the end', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + + moveForward(node); + + expectState(input, 'cat', 3); + }); + + test('moves directly to the start', () => { + const { input, node } = setup(); + typeText(node, 'cat'); + + moveMin(node); + + expectState(input, 'cat', 0); + }); + + test('moves directly to the code-point end', () => { + const { input, node } = setup(); + typeText(node, 'a😀b'); + moveMin(node); + + moveMax(node); + + expectState(input, 'a😀b', 3); + }); + }); + + test('keeps state isolated between input elements', () => { + const host = createHost(); + FocusApi.init(host.root); + useInputElement(host); + const first = createInput(host); + const second = createInput(host); + + insert(first.node!, 'x'); + + expectState(first, 'x', 1); + expectState(second, '', 0); + }); + }); + + describe('input event', () => { + test('types the oninput prop with the registered input event', () => { + expectTypeOf>() + .parameter(0) + .toEqualTypeOf(); + }); + + test('emits the updated value after each content mutation', () => { + const { host, input, node } = setup(); + const events: HostEvents['input'][] = []; + host.addEventListener(input, 'input', (event) => { + expectTypeOf(event).toEqualTypeOf(); + events.push(event); + }); + + insert(node, 'cat'); + deleteBackward(node); + moveMin(node); + deleteForward(node); + + expect( + events.map((event) => ({ + type: event.type, + target: event.target === input, + value: event.value, + })), + ).toEqual([ + { type: 'input', target: true, value: 'cat' }, + { type: 'input', target: true, value: 'ca' }, + { type: 'input', target: true, value: 'a' }, + ]); + }); + + test('does not emit when an action leaves the content unchanged', () => { + const { host, input, node } = setup(); + let events = 0; + host.addEventListener(input, 'input', () => events++); + + insert(node, ''); + insert(node, '\r\n'); + deleteBackward(node); + deleteForward(node); + moveBack(node); + moveForward(node); + moveMin(node); + moveMax(node); + + expect(events).toBe(0); + }); + }); + + describe('keymap', () => { + test.each([ + { + name: 'inserts printable text', + event: keydown('x', 'x'), + expected: ['insert', 'x'], + }, + { + name: 'maps Backspace to deleteBackward', + event: keydown('Backspace'), + expected: ['deleteBackward'], + }, + { + name: 'maps Delete to deleteForward', + event: keydown('Delete'), + expected: ['deleteForward'], + }, + { + name: 'maps ArrowLeft to moveBack', + event: keydown('ArrowLeft'), + expected: ['moveBack'], + }, + { + name: 'maps ArrowRight to moveForward', + event: keydown('ArrowRight'), + expected: ['moveForward'], + }, + { + name: 'maps Home to moveMin', + event: keydown('Home'), + expected: ['moveMin'], + }, + { + name: 'maps End to moveMax', + event: keydown('End'), + expected: ['moveMax'], + }, + ])('$name', ({ event, expected }) => { + const { host, node } = setupKeyboardInput(); + const calls: InputCall[] = []; + recordInput(node, calls); + + DispatchApi.at(host.root).dispatch(event); + + const [method, argument] = expected; + expect(calls).toEqual([argument === undefined ? [method] : [method, argument]]); + }); + + test('uses input keydown behavior for keyrepeat', () => { + const { host, node } = setupKeyboardInput(); + const calls: InputCall[] = []; + recordInput(node, calls); + const event: KeyRepeat = { + type: 'keyrepeat', + key: 'x', + code: 'x', + text: 'x', + }; + + DispatchApi.at(host.root).dispatch(event); + + expect(calls).toEqual([['insert', 'x']]); + }); + + test('allows an ancestor decorator to observe a recognized input binding', () => { + const { host, node } = setupKeyboardInput(); + const calls: InputCall[] = []; + let delegations = 0; + recordInput(node, calls); + recordDelegatedKeydown(host, () => delegations++); + + DispatchApi.at(host.root).dispatch(keydown('x', 'x')); + + expect(calls).toEqual([['insert', 'x']]); + expect(delegations).toBe(1); + }); + + test('delegates an unrecognized input key exactly once', () => { + const { host, node } = setupKeyboardInput(); + const event = keydown('Enter'); + const delegated: Array<{ target: boolean; event: boolean }> = []; + recordDelegatedKeydown(host, (target, received) => { + delegated.push({ target: target === node, event: received === event }); + }); + + DispatchApi.at(host.root).dispatch(event); + + expect(delegated).toEqual([{ target: true, event: true }]); + }); + + test('delegates a key targeting a non-input node exactly once', () => { + const host = setupKeyboardHost(); + useInputElement(host); + const target = createFocusable(host); + const event = keydown('x', 'x'); + const delegated: Array<{ target: boolean; event: boolean }> = []; + recordDelegatedKeydown(host, (node, received) => { + delegated.push({ target: node === target, event: received === event }); + }); + + DispatchApi.at(host.root).dispatch(event); + + expect(delegated).toEqual([{ target: true, event: true }]); + }); + + test('makes the first attached input the keyboard target', () => { + const { host, node } = setupKeyboardInput({ focus: false }); + const calls: InputCall[] = []; + recordInput(node, calls); + + expect(getFocus(host.root) === node).toBe(true); + DispatchApi.at(host.root).dispatch(keydown('x', 'x')); + expect(calls).toEqual([['insert', 'x']]); + }); + }); +}); + +type InputCall = [method: string, argument?: string]; + +function setup(): { host: Host; input: HostElement; node: Node } { + const host = createHost(); + FocusApi.init(host.root); + useInputElement(host); + const input = createInput(host); + return { host, input, node: input.node! }; +} + +function setupKeyboardHost(): Host { + const host = createHost(); + DispatchApi.init(host.root); + FocusApi.init(host.root); + KeyboardApi.init(host.root); + return host; +} + +function setupKeyboardInput(options: { focus?: boolean } = {}): { + host: Host; + node: Node; +} { + const host = setupKeyboardHost(); + useInputElement(host); + const input = createInput(host); + const node = input.node!; + if (options.focus ?? true) { + setFocusable(node); + } + return { host, node }; +} + +function createInput(host: Host): HostElement { + const input = host.createElement('input'); + host.insertBefore(host.element, input); + return input; +} + +function typeText(node: Node, value: string): void { + for (const codePoint of value) { + insert(node, codePoint); + } +} + +function deleteBackward(node: Node): void { + InputApi.at(node).deleteBackward(); +} + +function deleteForward(node: Node): void { + InputApi.at(node).deleteForward(); +} + +function insert(node: Node, text: string): void { + InputApi.at(node).insert(text); +} + +function moveBack(node: Node): void { + InputApi.at(node).moveBack(); +} + +function moveForward(node: Node): void { + InputApi.at(node).moveForward(); +} + +function moveMax(node: Node): void { + InputApi.at(node).moveMax(); +} + +function moveMin(node: Node): void { + InputApi.at(node).moveMin(); +} + +function keydown(code: KeyDown['code'], text?: string): KeyDown { + return { + type: 'keydown', + key: text ?? code, + code, + ...(text === undefined ? {} : { text }), + }; +} + +function createFocusable(host: Host): Node { + const element = host.createElement('box'); + host.insertBefore(host.element, element); + setFocusable(element.node!); + return element.node!; +} + +function getFocus(node: Node): Node { + return FocusApi.at(node).getFocus(); +} + +function setFocusable(node: Node): void { + FocusApi.at(node).setFocusable(); +} + +function recordInput(node: Node, calls: InputCall[]): void { + InputApi.decorate(node, () => ({ + insert(text) { + calls.push(['insert', text]); + }, + deleteBackward() { + calls.push(['deleteBackward']); + }, + deleteForward() { + calls.push(['deleteForward']); + }, + moveBack() { + calls.push(['moveBack']); + }, + moveForward() { + calls.push(['moveForward']); + }, + moveMin() { + calls.push(['moveMin']); + }, + moveMax() { + calls.push(['moveMax']); + }, + })); +} + +function recordDelegatedKeydown(host: Host, listener: (node: Node, event: KeyDown) => void): void { + KeyboardApi.decorate(host.root, () => ({ + keydown(event) { + listener(this.node, event); + return super.keydown(event); + }, + })); +} + +// oxlint-disable-next-line bombshell-dev/max-params +function expectState(input: HostElement, value: string, caret: number): void { + expect({ + value: input.properties['value'], + caret: input.properties['caret'], + }).toEqual({ value, caret }); +} diff --git a/packages/ui/src/elements/input.ts b/packages/ui/src/elements/input.ts new file mode 100644 index 0000000..89f52b6 --- /dev/null +++ b/packages/ui/src/elements/input.ts @@ -0,0 +1,212 @@ +import { lifecycle, type Node } from '../core.ts'; +import { open, close, text, fit, percent, rgba, type KeyDown, type KeyRepeat } from '@bomb.sh/tty'; +import type { HostEvent } from '@clack/ui/events'; +import { getElement, type HostElement } from '../elements.ts'; +import { type Host, HostApi } from '../host.ts'; +import { LayoutApi } from '../layout.ts'; +import { FocusApi } from '../focus.ts'; +import { KeyboardApi } from '../keyboard.ts'; +import { Api } from '../core/api.ts'; + +export interface InputEvent extends HostEvent<'input'> { + value: string; +} + +declare module '@clack/ui/events' { + interface HostEvents { + input: InputEvent; + } +} + +export interface InputProps { + oninput?: (event: InputEvent) => void; +} + +declare module '@clack/ui/elements' { + interface HostElements { + input: InputProps; + } +} + +export function useInputElement(host: Host): void { + const { root } = host; + + HostApi.decorate(root, () => ({ + createElement(name) { + const element: HostElement = super.createElement(name); + if (element.name === 'input') { + element.attach = (node) => { + FocusApi.at(node).setFocusable(); + InputApi.init(node); + }; + + // make value and caret accessible to the public + Object.defineProperties(element.properties, { + value: { + get() { + return InputApi.at(element.node!).content; + }, + }, + caret: { + get() { + return InputApi.at(element.node!).caret; + }, + }, + }); + } + return element; + }, + })); + + LayoutApi.decorate(root, () => ({ + *layout() { + const { node } = this; + const element = getElement(node); + if (element.name !== 'input') { + return yield* super.layout(); + } + + const focused = node === FocusApi.at(node).getFocus(); + const input = InputApi.at(node); + const value = input.content; + // commented out until @bomb.sh/tty can render carets + // const caret = Math.min(model.caret, [...value].length); + const color = focused ? rgba(255, 255, 255) : rgba(100, 100, 100); + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + yield open(lifecycle.id(node), { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }); + yield text(value || ' '); + yield close(); + }, + })); +} + +export class InputApi extends Api { + content = ''; + caret = 0; + + constructor(location: Node) { + super(location); + + KeyboardApi.decorate(location, () => ({ + keydown(event) { + const { node } = this; + if (!handleKeyEvent(node, event)) { + return super.keydown(event); + } + }, + keyrepeat(event) { + const { node } = this; + if (!handleKeyEvent(node, event)) { + return super.keyrepeat(event); + } + }, + })); + } + + insert(text: string): void { + this.update(() => { + // Inputs are single-line, so discard CR/LF. Spreading the string produces + // Unicode code points rather than UTF-16 code units. + const inserted = [...text.replace(/[\r\n]/g, '')]; + const codepoints = [...this.content]; + const caret = this.caret; + + // The caret is a code-point index. Insert each new code point independently, + // then advance by exactly the number that survived normalization. + codepoints.splice(caret, 0, ...inserted); + this.content = codepoints.join(''); + this.caret = caret + inserted.length; + }); + } + deleteBackward(): void { + this.update(() => { + const codepoints = [...this.content]; + let caret = this.caret; + const length = codepoints.length; + if (caret > length) { + console.warn('caret mismatch: ${caret}, value length: ${length}'); + caret = length; + } + if (caret > 0) { + codepoints.splice(caret - 1, 1); + this.content = codepoints.join(''); + this.caret = caret - 1; + } + }); + } + deleteForward(): void { + this.update(() => { + const codepoints = [...this.content]; + let caret = this.caret; + const length = codepoints.length; + if (caret > length) { + console.warn('caret mismatch: ${caret}, value length: ${length}'); + caret = length; + } + if (caret < length) { + codepoints.splice(caret, 1); + this.content = codepoints.join(''); + } + }); + } + moveBack(): void { + const caret = this.caret; + this.caret = Math.max(0, caret - 1); + } + moveForward(): void { + const caret = this.caret; + const codepoints = [...this.content]; + this.caret = Math.min(codepoints.length, caret + 1); + } + moveMin(): void { + this.caret = 0; + } + moveMax(): void { + this.caret = [...this.content].length; + } + update(fn: () => T): T { + const original = this.content; + const value = fn(); + if (original !== this.content) { + HostApi.at(this.node).emit({ + type: 'input', + value: this.content, + }); + } + return value; + } +} + +function handleKeyEvent(node: Node, event: KeyDown | KeyRepeat): boolean { + const input = InputApi.at(node); + if (event.text && event.text?.length > 0) { + input.insert(event.text); + return true; + } else if (event.code === 'Backspace') { + input.deleteBackward(); + return true; + } else if (event.code === 'Delete') { + input.deleteForward(); + return true; + } else if (event.code === 'ArrowLeft') { + input.moveBack(); + return true; + } else if (event.code === 'ArrowRight') { + input.moveForward(); + return true; + } else if (event.code === 'Home') { + input.moveMin(); + return true; + } else if (event.code === 'End') { + input.moveMax(); + return true; + } + return false; +}