import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RichTextEditor } from './rich-text-editor'; import { RICH_TEXT_EDITOR_CHANGE_EVENT, RICH_TEXT_EDITOR_COMMAND_EVENT, type RichTextEditorChangeEvent, type RichTextEditorCommandEvent, } from './rich-text-editor.types'; const MARKUP = `
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function content(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-rte-content]')!; } function button(root: HTMLElement, command: string): HTMLElement { return root.querySelector(`[data-c42-rte-command="${command}"]`)!; } describe('RichTextEditor', () => { let execSpy: ReturnType; let stateSpy: ReturnType; beforeEach(() => { document.body.innerHTML = ''; execSpy = vi.fn(() => true); stateSpy = vi.fn(() => false); // jsdom doesn't implement these — provide controllable stubs. (document as unknown as { execCommand: unknown }).execCommand = execSpy; (document as unknown as { queryCommandState: unknown }).queryCommandState = stateSpy; }); afterEach(() => { vi.restoreAllMocks(); }); it('makes the content editable and wires ARIA roles', () => { const root = setup(); new RichTextEditor(root); const area = content(root); expect(area.getAttribute('contenteditable')).toBe('true'); expect(area.getAttribute('role')).toBe('textbox'); expect(area.getAttribute('aria-multiline')).toBe('true'); expect(root.querySelector('[data-c42-rte-toolbar]')!.getAttribute('role')).toBe('toolbar'); }); it('throws when the content area is missing', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new RichTextEditor(root)).toThrow(/content/); }); it('seeds initial value and marks empty state', () => { const root = setup(); const editor = new RichTextEditor(root, { value: '

Hi

' }); expect(editor.getHTML()).toBe('

Hi

'); expect(content(root).hasAttribute('data-empty')).toBe(false); }); it('flags an empty editor with data-empty for the placeholder', () => { const root = setup(); new RichTextEditor(root); expect(content(root).hasAttribute('data-empty')).toBe(true); }); it('runs execCommand when a toolbar button is clicked', () => { const root = setup(); new RichTextEditor(root); button(root, 'bold').click(); expect(execSpy).toHaveBeenCalledWith('bold', false, undefined); }); it('prompts for a URL on createLink and passes it to execCommand', () => { const root = setup(); new RichTextEditor(root, { getLinkUrl: () => 'https://example.com' }); button(root, 'createLink').click(); expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://example.com'); }); it('cancels createLink when no URL is provided', () => { const root = setup(); new RichTextEditor(root, { getLinkUrl: () => null }); button(root, 'createLink').click(); expect(execSpy).not.toHaveBeenCalled(); }); it('reflects toggle command state on the buttons', () => { const root = setup(); stateSpy.mockImplementation((command: string) => command === 'bold'); const editor = new RichTextEditor(root); editor.format('bold'); expect(button(root, 'bold').getAttribute('aria-pressed')).toBe('true'); expect(button(root, 'bold').dataset.active).toBe('true'); expect(button(root, 'italic').getAttribute('aria-pressed')).toBe('false'); }); it('emits a command event then a change event on format()', () => { const root = setup(); const editor = new RichTextEditor(root); const onCommand = vi.fn(); const onChange = vi.fn(); editor.on(RICH_TEXT_EDITOR_COMMAND_EVENT, onCommand); editor.on(RICH_TEXT_EDITOR_CHANGE_EVENT, onChange); editor.format('italic'); expect(onCommand.mock.calls[0][0].detail.command).toBe('italic'); expect(onChange).toHaveBeenCalled(); }); it('emits change on content input', () => { const root = setup(); const editor = new RichTextEditor(root); const onChange = vi.fn(); editor.on(RICH_TEXT_EDITOR_CHANGE_EVENT, onChange); content(root).innerHTML = 'typed'; content(root).dispatchEvent(new Event('input', { bubbles: true })); expect(onChange).toHaveBeenCalledOnce(); expect(onChange.mock.calls[0][0].detail.html).toBe('typed'); }); it('setHTML replaces content and emits change', () => { const root = setup(); const editor = new RichTextEditor(root); const onChange = vi.fn(); editor.on(RICH_TEXT_EDITOR_CHANGE_EVENT, onChange); editor.setHTML('

new

'); expect(editor.getHTML()).toBe('

new

'); expect(onChange).toHaveBeenCalledOnce(); }); it('degrades gracefully when execCommand is unavailable', () => { const root = setup(); delete (document as unknown as { execCommand?: unknown }).execCommand; const editor = new RichTextEditor(root); expect(() => editor.format('bold')).not.toThrow(); }); it('removes listeners on destroy', () => { const root = setup(); const editor = new RichTextEditor(root); editor.destroy(); button(root, 'bold').click(); expect(execSpy).not.toHaveBeenCalled(); }); });