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