import { uid } from '../shared/dom'; import { segment } from './sms-segmenter'; import { SMS_EDITOR_CHANGE_EVENT, type SmsEditorChangeDetail, type SmsEditorOptions, } from './sms-editor.types'; const SELECTORS = { input: '[data-c42-sms-input]', trigger: '[data-c42-sms-trigger]', picker: '[data-c42-sms-picker]', counter: '[data-c42-sms-counter]', segments: '[data-c42-sms-segments]', encoding: '[data-c42-sms-encoding]', preview: '[data-c42-sms-preview]', } as const; /** * Headless SMS message editor. Enhances a ` *
* * * * * * * *
* *
* * ``` */ export class SmsEditor { private readonly root: HTMLElement; private readonly input: HTMLTextAreaElement; private readonly trigger: HTMLElement | null; private readonly picker: HTMLElement | null; private readonly counterEl: HTMLElement | null; private readonly segmentsEl: HTMLElement | null; private readonly encodingEl: HTMLElement | null; private readonly preview: HTMLElement | null; private readonly maxLength?: number; private readonly maxSegments?: number; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SmsEditorOptions = {}) { const input = root.querySelector(SELECTORS.input); if (!input) { throw new Error('[42/sms-editor] Needs a [data-c42-sms-input] textarea.'); } this.root = root; this.input = input; this.trigger = root.querySelector(SELECTORS.trigger); this.picker = root.querySelector(SELECTORS.picker); this.counterEl = root.querySelector(SELECTORS.counter); this.segmentsEl = root.querySelector(SELECTORS.segments); this.encodingEl = root.querySelector(SELECTORS.encoding); this.preview = root.querySelector(SELECTORS.preview); this.maxLength = options.maxLength; this.maxSegments = options.maxSegments; this.init(options.value); 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) => this.insertText(emoji)); } } private init(value?: string): void { this.input.setAttribute('role', 'textbox'); this.input.setAttribute('aria-multiline', 'true'); if (this.maxLength !== undefined) { this.input.setAttribute('maxlength', String(this.maxLength)); } if (!this.input.id) { this.input.id = uid('sms-input'); } if (value !== undefined) { this.input.value = this.maxLength !== undefined ? value.slice(0, this.maxLength) : value; } this.root.setAttribute('data-picker-open', 'false'); this.picker?.setAttribute('hidden', ''); this.listen(this.input, 'input', () => this.sync()); if (this.trigger) { if (this.trigger instanceof HTMLButtonElement && !this.trigger.hasAttribute('type')) { this.trigger.type = 'button'; } this.listen(this.trigger, 'click', () => this.togglePicker()); } this.listen(document, 'click', (e) => { if (this.isPickerOpen() && !this.root.contains(e.target as Node)) { this.closePicker(); } }); this.listen(document, 'keydown', (e) => { if ((e as KeyboardEvent).key === 'Escape' && this.isPickerOpen()) { this.closePicker(); } }); this.sync(); } private listen(el: EventTarget, event: string, handler: (e: Event) => void): void { el.addEventListener(event, handler); this.cleanups.push(() => el.removeEventListener(event, handler)); } /** Recompute segmentation, reflect it on the DOM and emit a change event. */ private sync(): void { const text = this.input.value; const info = segment(text); const overLimit = this.maxSegments !== undefined && info.segments > this.maxSegments; if (this.counterEl) { this.counterEl.textContent = String(info.length); } if (this.segmentsEl) { this.segmentsEl.textContent = String(info.segments); } if (this.encodingEl) { this.encodingEl.textContent = info.encoding; } if (this.preview) { // Plain text — textContent escapes it; CSS preserves newlines. this.preview.textContent = text; this.preview.toggleAttribute('data-empty', text.length === 0); } this.root.setAttribute('data-encoding', info.encoding); this.root.toggleAttribute('data-over-limit', overLimit); const detail: SmsEditorChangeDetail = { text, encoding: info.encoding, length: info.length, segments: info.segments, remaining: info.remaining, overLimit, }; this.root.dispatchEvent(new CustomEvent(SMS_EDITOR_CHANGE_EVENT, { detail, bubbles: true })); } // ─── Picker ──────────────────────────────────────────────────────────── isPickerOpen(): boolean { return this.root.getAttribute('data-picker-open') === 'true'; } togglePicker(): void { if (this.isPickerOpen()) { this.closePicker(); } else { this.openPicker(); } } openPicker(): void { this.root.setAttribute('data-picker-open', 'true'); this.picker?.removeAttribute('hidden'); } closePicker(): void { this.root.setAttribute('data-picker-open', 'false'); this.picker?.setAttribute('hidden', ''); } // ─── Public API ────────────────────────────────────────────────────────── /** Insert text (e.g. an emoji) at the caret, respecting maxLength if set. */ insertText(text: string): void { const start = this.input.selectionStart; const end = this.input.selectionEnd; const current = this.input.value; if ( this.maxLength !== undefined && current.length - (end - start) + text.length > this.maxLength ) { return; } this.input.value = current.slice(0, start) + text + current.slice(end); const pos = start + text.length; this.input.setSelectionRange(pos, pos); this.input.focus(); this.sync(); } /** Current message text. */ getText(): string { return this.input.value; } /** Current segmentation report. */ getSegmentInfo(): ReturnType { return segment(this.input.value); } get value(): string { return this.input.value; } set value(v: string) { this.input.value = this.maxLength !== undefined ? v.slice(0, this.maxLength) : v; this.sync(); } /** Focus the textarea. */ focus(): void { this.input.focus(); } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ 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 = []; } }