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');
});
});