import { TEXTAREA_EMOJI_INPUT_EVENT, TEXTAREA_EMOJI_EMOJI_EVENT, type TextareaEmojiOptions, type TextareaEmojiInputDetail, type TextareaEmojiEmojiDetail, } from './textarea-emoji.types'; const SELECTORS = { textarea: '[data-c42-textarea-emoji-input]', trigger: '[data-c42-textarea-emoji-trigger]', picker: '[data-c42-textarea-emoji-picker]', counter: '[data-c42-textarea-emoji-counter]', } as const; /** * Headless textarea with emoji-picker integration. * Manages: picker open/close, emoji insertion at cursor, character counter, * maxLength enforcement, and outside-click dismissal. * * Markup: * ```html *
* * *
* *
* ``` */ export class TextareaEmoji { private readonly root: HTMLElement; private readonly textarea: HTMLTextAreaElement; private readonly trigger: HTMLElement | null; private readonly picker: HTMLElement | null; private readonly counter: HTMLElement | null; private readonly maxLength: number; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: TextareaEmojiOptions = {}) { this.root = root; this.maxLength = options.maxLength ?? 200; const textarea = root.querySelector(SELECTORS.textarea); if (!textarea) { throw new Error('[42/textarea-emoji] Missing [data-c42-textarea-emoji-input] textarea.'); } this.textarea = textarea; this.trigger = root.querySelector(SELECTORS.trigger); this.picker = root.querySelector(SELECTORS.picker); this.counter = root.querySelector(SELECTORS.counter); this.init(); const emojisEnabled = options.emojis !== false; if (!emojisEnabled) { this.trigger?.setAttribute('hidden', ''); this.picker?.setAttribute('hidden', ''); } else if (options.renderPicker && this.picker) { options.renderPicker(this.picker, (emoji: string) => this.insertEmoji(emoji)); } } private init(): void { this.textarea.setAttribute('maxlength', String(this.maxLength)); this.root.setAttribute('data-picker-open', 'false'); if (this.picker) this.picker.setAttribute('hidden', ''); this.updateCounter(); this.listen(this.textarea, 'input', () => { this.updateCounter(); this.emitInput(); }); if (this.trigger) { this.listen(this.trigger, 'click', () => this.toggle()); } this.listen(document, 'click', (e: Event) => { if (!this.isOpen()) return; const target = e.target as Node; if (!this.root.contains(target)) this.close(); }); this.listen(document, 'keydown', (e: Event) => { if ((e as KeyboardEvent).key === 'Escape' && this.isOpen()) this.close(); }); } private listen(el: EventTarget, event: string, handler: (e: Event) => void): void { el.addEventListener(event, handler); this.cleanups.push(() => el.removeEventListener(event, handler)); } private updateCounter(): void { if (!this.counter) return; this.counter.textContent = `${this.textarea.value.length}/${this.maxLength}`; } private emitInput(): void { const detail: TextareaEmojiInputDetail = { value: this.textarea.value, length: this.textarea.value.length, }; this.root.dispatchEvent(new CustomEvent(TEXTAREA_EMOJI_INPUT_EVENT, { detail, bubbles: true })); } isOpen(): boolean { return this.root.getAttribute('data-picker-open') === 'true'; } toggle(): void { if (this.isOpen()) { this.close(); } else { this.open(); } } open(): void { this.root.setAttribute('data-picker-open', 'true'); this.picker?.removeAttribute('hidden'); } close(): void { this.root.setAttribute('data-picker-open', 'false'); this.picker?.setAttribute('hidden', ''); } /** Insert an emoji (or any string) at the current cursor position. */ insertEmoji(emoji: string): void { const start = this.textarea.selectionStart; const end = this.textarea.selectionEnd; const current = this.textarea.value; if (current.length - (end - start) + emoji.length > this.maxLength) return; this.textarea.value = current.slice(0, start) + emoji + current.slice(end); const newPos = start + emoji.length; this.textarea.setSelectionRange(newPos, newPos); this.textarea.focus(); this.updateCounter(); this.emitInput(); const detail: TextareaEmojiEmojiDetail = { emoji, value: this.textarea.value }; this.root.dispatchEvent( new CustomEvent(TEXTAREA_EMOJI_EMOJI_EVENT, { detail, bubbles: true }), ); } get value(): string { return this.textarea.value; } set value(v: string) { this.textarea.value = v.slice(0, this.maxLength); this.updateCounter(); } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }