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 = [];
}
}