import { CLIPBOARD_COPY_EVENT, CLIPBOARD_ERROR_EVENT, type ClipboardCopyDetail, type ClipboardErrorDetail, type ClipboardOptions, } from './clipboard.types'; const SELECTORS = { trigger: '[data-c42-clipboard-trigger]', source: '[data-c42-clipboard-source]', } as const; const DEFAULT_TIMEOUT = 2000; /** * Headless clipboard controller. Wires a trigger button to copy text to the * system clipboard and reflects a temporary `data-state="copied"` on the root * so CSS can swap an icon/label; it never applies visual styles itself. * * The text comes from (in priority order): the `text` option, or the * `[data-c42-clipboard-source]` element (its `value` for inputs/textareas, * otherwise its `textContent`). * * Markup: * ```html *
* * *
* ``` */ export class Clipboard { private readonly root: HTMLElement; private readonly trigger: HTMLElement; private readonly source: HTMLElement | null; private text: string | null; private readonly timeout: number; private timer: ReturnType | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: ClipboardOptions = {}) { const trigger = root.querySelector(SELECTORS.trigger); if (!trigger) { throw new Error('[42/clipboard] Needs a [data-c42-clipboard-trigger] element.'); } this.root = root; this.trigger = trigger; this.source = root.querySelector(SELECTORS.source); this.text = options.text ?? null; this.timeout = options.timeout ?? DEFAULT_TIMEOUT; if (trigger instanceof HTMLButtonElement && !trigger.hasAttribute('type')) { trigger.type = 'button'; } this.root.dataset.state = 'idle'; const onClick = (): void => { void this.copy(); }; this.trigger.addEventListener('click', onClick); this.cleanups.push(() => this.trigger.removeEventListener('click', onClick)); } private resolveText(): string { if (this.text != null) { return this.text; } const el = this.source; if (!el) { return ''; } if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { return el.value; } return el.textContent ?? ''; } private async write(text: string): Promise { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } // Legacy fallback for environments without the async Clipboard API. const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'absolute'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } finally { document.body.removeChild(ta); } if (!ok) { throw new Error('[42/clipboard] copy command was rejected by the browser.'); } } private flashCopied(): void { this.root.dataset.state = 'copied'; if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => { this.root.dataset.state = 'idle'; this.timer = null; }, this.timeout); } private emit(name: string, detail: D): void { this.root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); } /** Set/override the text that the trigger copies. */ setText(text: string | null): void { this.text = text; } /** Programmatically copy the current text. Resolves to whether it succeeded. */ async copy(): Promise { const text = this.resolveText(); try { await this.write(text); this.flashCopied(); this.emit(CLIPBOARD_COPY_EVENT, { text }); return true; } catch (error) { this.emit(CLIPBOARD_ERROR_EVENT, { error }); return false; } } /** 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 { if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }