import { beforeEach, describe, expect, it, vi } from 'vitest'; import { WhatsappEditor } from './whatsapp-editor'; import { WHATSAPP_EDITOR_CHANGE_EVENT, WHATSAPP_EDITOR_FORMAT_EVENT, type WhatsappEditorChangeEvent, type WhatsappEditorFormatEvent, } from './whatsapp-editor.types'; const MARKUP = `
`; function setup(opts = {}): { root: HTMLElement; ctrl: WhatsappEditor } { document.body.innerHTML = MARKUP; const root = document.body.firstElementChild as HTMLElement; return { root, ctrl: new WhatsappEditor(root, opts) }; } function input(root: HTMLElement): HTMLTextAreaElement { return root.querySelector('[data-c42-wa-input]')!; } function button(root: HTMLElement, cmd: string): HTMLButtonElement { return root.querySelector(`[data-c42-wa-command="${cmd}"]`)!; } function floating(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-wa-floating]')!; } /** Focus the textarea, set a selection range, and fire `selectionchange`. */ function selectRange(ta: HTMLTextAreaElement, start: number, end: number): void { ta.focus(); ta.setSelectionRange(start, end); document.dispatchEvent(new Event('selectionchange')); } describe('WhatsappEditor', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('throws without a textarea', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new WhatsappEditor(root)).toThrow(/data-c42-wa-input/); }); it('sets ARIA roles and maxlength', () => { const { root } = setup({ maxLength: 100 }); expect(input(root).getAttribute('role')).toBe('textbox'); expect(input(root).getAttribute('maxlength')).toBe('100'); expect(root.querySelector('[data-c42-wa-toolbar]')!.getAttribute('role')).toBe('toolbar'); }); it('seeds initial value and renders preview', () => { const { root } = setup({ value: '*hi*' }); expect(input(root).value).toBe('*hi*'); expect(root.querySelector('[data-c42-wa-preview]')!.innerHTML).toBe('hi'); }); it('updates preview + counter on input', () => { const { root } = setup({ maxLength: 50 }); const ta = input(root); ta.value = '_hey_'; ta.dispatchEvent(new Event('input', { bubbles: true })); expect(root.querySelector('[data-c42-wa-preview]')!.innerHTML).toBe('hey'); expect(root.querySelector('[data-c42-wa-counter]')!.textContent).toBe('5/50'); }); it('wraps selection in bold via toolbar', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.setSelectionRange(6, 11); button(root, 'bold').click(); expect(ta.value).toBe('hello *world*'); }); it('toggles bold off when re-applied to the same selection', () => { const { root, ctrl } = setup(); const ta = input(root); ta.value = 'abc'; ta.setSelectionRange(0, 3); ctrl.format('bold'); expect(ta.value).toBe('*abc*'); // Selection now spans the inner text; re-toggle unwraps. ctrl.format('bold'); expect(ta.value).toBe('abc'); }); it('applies a blockquote via the toolbar button', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello'; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.setSelectionRange(0, 0); button(root, 'blockquote').click(); expect(ta.value).toBe('> hello'); }); it('applies a numbered list across selected lines', () => { const { root, ctrl } = setup(); const ta = input(root); ta.value = 'one\ntwo'; ta.setSelectionRange(0, ta.value.length); ctrl.applyBlock('ordered'); expect(ta.value).toBe('1. one\n2. two'); }); it('applies a bullet list via the toolbar button', () => { const { root } = setup(); const ta = input(root); ta.value = 'milk'; ta.setSelectionRange(0, 0); button(root, 'bullet').click(); expect(ta.value).toBe('- milk'); }); it('clears formatting from the whole message when nothing is selected', () => { const { root } = setup(); const ta = input(root); ta.value = '*bold* _italic_\n> quote'; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.setSelectionRange(0, 0); // collapsed → whole text button(root, 'clear').click(); expect(ta.value).toBe('bold italic\nquote'); }); it('clears formatting only within the selection', () => { const { root, ctrl } = setup(); const ta = input(root); ta.value = '*a* *b*'; ta.setSelectionRange(0, 3); // just "*a*" ctrl.clearFormatting(); expect(ta.value).toBe('a *b*'); }); it('emits a format event with marker "clear"', () => { const { root, ctrl } = setup(); const spy = vi.fn(); root.addEventListener(WHATSAPP_EDITOR_FORMAT_EVENT, spy); input(root).value = '*x*'; input(root).setSelectionRange(0, 3); ctrl.clearFormatting(); expect((spy.mock.calls[0][0] as WhatsappEditorFormatEvent).detail.marker).toBe('clear'); }); it('applies italic via Ctrl+I shortcut', () => { const { root } = setup(); const ta = input(root); ta.value = 'word'; ta.setSelectionRange(0, 4); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'i', ctrlKey: true, bubbles: true })); expect(ta.value).toBe('_word_'); }); it('inserts emoji at cursor', () => { const { root, ctrl } = setup(); const ta = input(root); ta.value = 'ab'; ta.setSelectionRange(1, 1); ctrl.insertText('🎉'); expect(ta.value).toBe('a🎉b'); }); it('opens and closes the picker via trigger', () => { const { root } = setup(); const trigger = root.querySelector('[data-c42-wa-trigger]')!; const picker = root.querySelector('[data-c42-wa-picker]')!; expect(picker.hasAttribute('hidden')).toBe(true); trigger.click(); expect(root.getAttribute('data-picker-open')).toBe('true'); expect(picker.hasAttribute('hidden')).toBe(false); trigger.click(); expect(root.getAttribute('data-picker-open')).toBe('false'); }); it('closes the picker on Escape', () => { const { ctrl } = setup(); ctrl.openPicker(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(ctrl.isPickerOpen()).toBe(false); }); it('calls renderPicker plugin once', () => { const renderPicker = vi.fn(); setup({ renderPicker }); expect(renderPicker).toHaveBeenCalledOnce(); }); it('hides emoji affordances when emojis:false', () => { const { root } = setup({ emojis: false }); expect(root.querySelector('[data-c42-wa-trigger]')!.hasAttribute('hidden')).toBe(true); }); it('emits change with text + html', () => { const { root } = setup(); const spy = vi.fn(); root.addEventListener(WHATSAPP_EDITOR_CHANGE_EVENT, spy); const ta = input(root); ta.value = '*x*'; ta.dispatchEvent(new Event('input', { bubbles: true })); const detail = (spy.mock.calls.at(-1)![0] as WhatsappEditorChangeEvent).detail; expect(detail.text).toBe('*x*'); expect(detail.html).toBe('x'); }); it('emits format event on toolbar toggle', () => { const { root, ctrl } = setup(); const spy = vi.fn(); root.addEventListener(WHATSAPP_EDITOR_FORMAT_EVENT, spy); input(root).value = 'hi'; input(root).setSelectionRange(0, 2); ctrl.format('strikethrough'); expect((spy.mock.calls[0][0] as WhatsappEditorFormatEvent).detail.marker).toBe('strikethrough'); }); it('getText / getHTML reflect current value', () => { const { ctrl } = setup({ value: '~done~' }); expect(ctrl.getText()).toBe('~done~'); expect(ctrl.getHTML()).toBe('done'); }); // ─── Floating selection menu ───────────────────────────────────────────── it('initializes the floating menu hidden and closed', () => { const { root } = setup(); const el = floating(root); expect(el.getAttribute('role')).toBe('toolbar'); expect(el.getAttribute('data-state')).toBe('closed'); expect(el.hasAttribute('hidden')).toBe(true); }); it('shows the floating menu when text is selected', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; ta.dispatchEvent(new Event('input', { bubbles: true })); selectRange(ta, 0, 5); expect(floating(root).getAttribute('data-state')).toBe('open'); expect(floating(root).hasAttribute('hidden')).toBe(false); }); it('hides the floating menu when the selection collapses', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; selectRange(ta, 0, 5); expect(floating(root).getAttribute('data-state')).toBe('open'); selectRange(ta, 3, 3); expect(floating(root).getAttribute('data-state')).toBe('closed'); }); it('hides the floating menu on Escape', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; selectRange(ta, 0, 5); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(floating(root).getAttribute('data-state')).toBe('closed'); }); it('hides the floating menu on blur', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; selectRange(ta, 0, 5); ta.dispatchEvent(new Event('blur')); expect(floating(root).getAttribute('data-state')).toBe('closed'); }); it('formats the selection from a floating-menu button', () => { const { root } = setup(); const ta = input(root); ta.value = 'hello world'; selectRange(ta, 6, 11); const floatingBold = floating(root).querySelector( '[data-c42-wa-command="bold"]', )!; floatingBold.click(); expect(ta.value).toBe('hello *world*'); }); it('reflects the active marker state on command buttons', () => { const { root } = setup(); const ta = input(root); ta.value = '*hi*'; ta.dispatchEvent(new Event('input', { bubbles: true })); selectRange(ta, 0, 4); // selection wraps "*hi*" expect(button(root, 'bold').getAttribute('aria-pressed')).toBe('true'); expect(button(root, 'bold').hasAttribute('data-active')).toBe(true); expect(button(root, 'italic').getAttribute('aria-pressed')).toBe('false'); }); it('clears the active marker state when the selection is unformatted', () => { const { root } = setup(); const ta = input(root); ta.value = 'plain text'; selectRange(ta, 0, 5); expect(button(root, 'bold').getAttribute('aria-pressed')).toBe('false'); expect(button(root, 'bold').hasAttribute('data-active')).toBe(false); }); it('works without a floating menu in the markup', () => { document.body.innerHTML = `
`; const root = document.body.firstElementChild as HTMLElement; const ctrl = new WhatsappEditor(root); const ta = input(root); ta.value = 'hello'; expect(() => selectRange(ta, 0, 5)).not.toThrow(); ctrl.destroy(); }); it('does not inject a floating menu by default', () => { document.body.innerHTML = `
`; const root = document.body.firstElementChild as HTMLElement; new WhatsappEditor(root); expect(root.querySelector('[data-c42-wa-floating]')).toBeNull(); }); it('injects a default floating menu when floating:true and none is provided', () => { document.body.innerHTML = `
`; const root = document.body.firstElementChild as HTMLElement; new WhatsappEditor(root, { floating: true }); const el = floating(root); expect(el).toBeTruthy(); expect(el.getAttribute('data-state')).toBe('closed'); expect(el.querySelectorAll('[data-c42-wa-command]').length).toBeGreaterThan(0); }); it('does not inject a default when the consumer already provides a floating menu', () => { const { root } = setup({ floating: true }); expect(root.querySelectorAll('[data-c42-wa-floating]')).toHaveLength(1); }); it('formats from the injected default menu and removes it on destroy', () => { document.body.innerHTML = `
`; const root = document.body.firstElementChild as HTMLElement; const ctrl = new WhatsappEditor(root, { floating: true }); const ta = input(root); ta.value = 'hello world'; selectRange(ta, 6, 11); floating(root).querySelector('[data-c42-wa-command="bold"]')!.click(); expect(ta.value).toBe('hello *world*'); ctrl.destroy(); expect(root.querySelector('[data-c42-wa-floating]')).toBeNull(); }); it('destroy removes listeners', () => { const { root, ctrl } = setup(); ctrl.openPicker(); ctrl.destroy(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(root.getAttribute('data-picker-open')).toBe('true'); }); });