* feat: Add contenteditable tokenizer for badge/code-chip chat input * feat: Add source-space undo/redo history for the rich input * feat: Split text glued to a closing code fence onto its own line * feat: Add ChatFormContenteditable rich input renderer * feat : wire the contenteditable into ChatForm with auto-switch gating
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
// Guards the editing-key contract of the chat-form contenteditable:
|
|
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
|
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
|
// keyboard trap), matching the plain textarea.
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { render } from 'vitest-browser-svelte';
|
|
import { tick } from 'svelte';
|
|
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
|
|
|
const SOURCE = 'see [docs](file:///a/b)';
|
|
|
|
function editableIn(container: HTMLElement): HTMLElement {
|
|
const el = container.querySelector('[role="textbox"]');
|
|
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
|
return el;
|
|
}
|
|
|
|
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
|
root.appendChild(document.createTextNode(text));
|
|
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
|
}
|
|
|
|
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
|
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
|
root.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
describe('ChatFormContenteditable undo/redo', () => {
|
|
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
type(root, ' more');
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
|
|
|
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
expect(undoEvent.defaultPrevented).toBe(true);
|
|
expect(screen.component.getValue()).toBe(SOURCE);
|
|
|
|
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
|
await tick();
|
|
expect(redoEvent.defaultPrevented).toBe(true);
|
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
|
});
|
|
|
|
it('redoes with Ctrl+Y as well', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
type(root, ' more');
|
|
await tick();
|
|
keydown(root, { key: 'z', metaKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe(SOURCE);
|
|
|
|
keydown(root, { key: 'y', ctrlKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
|
});
|
|
|
|
it('coalesces a typing burst into one undo step', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
type(root, 'd');
|
|
type(root, 'e');
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abcde');
|
|
|
|
keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abc');
|
|
});
|
|
|
|
it('keeps a newline as its own undo step', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
type(root, 'd');
|
|
type(root, '\n', 'insertLineBreak');
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abcd\n');
|
|
|
|
keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abcd');
|
|
|
|
keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abc');
|
|
});
|
|
|
|
it('is a no-op when there is nothing to undo', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
const event = keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(screen.component.getValue()).toBe('abc');
|
|
});
|
|
|
|
it('abandons the redo branch after a fresh edit', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
type(root, 'd');
|
|
await tick();
|
|
keydown(root, { key: 'z', ctrlKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abc');
|
|
|
|
type(root, 'e');
|
|
await tick();
|
|
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
|
await tick();
|
|
expect(screen.component.getValue()).toBe('abce');
|
|
});
|
|
});
|
|
|
|
describe('ChatFormContenteditable Tab key', () => {
|
|
it('does not trap Tab (focus can leave the editable)', async () => {
|
|
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
|
await tick();
|
|
|
|
const root = editableIn(screen.container);
|
|
const event = keydown(root, { key: 'Tab' });
|
|
|
|
expect(event.defaultPrevented).toBe(false);
|
|
});
|
|
});
|