import { LitElement, html, css, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import '../../IconButton/core/IconButton.js'; export interface CopyButtonProps { text?: string; label?: string; successLabel?: string; errorLabel?: string; timeout?: number; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'ghost' | 'monochrome'; } /** * @slot icon-copy - Custom icon to show in the default (not copied) state * @slot icon-copied - Custom icon to show in the copied state * @slot icon-error - Custom icon to show when copy fails * * Note: If providing custom icons, both icon-copy and icon-copied slots must be provided together. Providing only one will throw an error. * * @csspart button - The icon button element * @csspart icon - The icon container (all states) * @csspart icon-copy - The copy icon (default state) * @csspart icon-copied - The checkmark icon (copied state) * @csspart icon-error - The error icon (error state) * * @fires copy - Fired when text is successfully copied. Detail: { text: string } * @fires copy-error - Fired when copy fails. Detail: { error: Error } */ export class AgCopyButton extends LitElement implements CopyButtonProps { static styles = css` :host { display: inline-block; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } /* Stacked icon slots for smooth cross-fade between states */ .ag-icon-stack { position: relative; /* Make this a block-level box so the parent IconButton's flex centering can reliably center the stacked slots. Inline alignment can interfere with flex layout in some browsers, causing the "pushed down" effect. */ display: block; width: 1em; height: 1em; } /* Each slot occupies the full stack and is centered */ .ag-icon-slot { position: absolute; inset: 0; /* Use grid centering to avoid baseline/line-height shifts during transitions */ display: grid; place-items: center; opacity: 0; transition: opacity var(--ag-motion-fast, 150ms) ease-in-out; will-change: opacity; pointer-events: none; } .ag-icon-slot.visible { opacity: 1; pointer-events: auto; } /* Ensure slotted SVGs and fallback svgs size consistently to the IconButton's em-based icon box Use 1em so icons scale with the IconButton font-size and avoid layout shifts during cross-fade */ .ag-icon-slot::slotted(svg), .ag-icon-slot svg { width: 1em; height: 1em; max-inline-size: 100%; max-block-size: 100%; display: block; } `; /** * The text to copy to the clipboard */ @property({ type: String }) declare text: string; /** * The label for the button (aria-label) */ @property({ type: String }) declare label: string; /** * The label to show when the text has been copied */ @property({ type: String, attribute: 'success-label' }) declare successLabel: string; /** * The label to show when copy fails */ @property({ type: String, attribute: 'error-label' }) declare errorLabel: string; /** * Duration in milliseconds to show the success state */ @property({ type: Number }) declare timeout: number; /** * Size of the button */ @property({ type: String }) declare size: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; /** * Variant of the button */ @property({ type: String }) declare variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'ghost' | 'monochrome'; @state() private _isCopied = false; @state() private _hasError = false; @state() private _announcement = ''; private _timeoutId?: number; constructor() { super(); // Initialize reactive properties in constructor this.text = ''; this.label = 'Copy to clipboard'; this.successLabel = 'Copied!'; this.errorLabel = 'Copy failed'; this.timeout = 1000; this.size = 'md'; this.variant = 'ghost'; } override disconnectedCallback() { super.disconnectedCallback(); if (this._timeoutId) { clearTimeout(this._timeoutId); } } override firstUpdated() { this._validateSlots(); } private _validateSlots() { const copySlot = this.querySelector('[slot="icon-copy"]'); const copiedSlot = this.querySelector('[slot="icon-copied"]'); const hasCopySlot = !!copySlot; const hasCopiedSlot = !!copiedSlot; // If one slot is provided but not the other, throw an error if (hasCopySlot !== hasCopiedSlot) { throw new Error( 'AgCopyButton: Both "icon-copy" and "icon-copied" slots must be provided together. ' + `Currently provided: ${hasCopySlot ? 'icon-copy' : 'icon-copied'} only.` ); } } private async _copyWithFallback(text: string): Promise { // Try modern Clipboard API first if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } // Fallback to execCommand for older browsers or non-secure contexts const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const successful = document.execCommand('copy'); if (!successful) { throw new Error('execCommand copy failed'); } } finally { document.body.removeChild(textArea); } } private _handleCopy() { if (!this.text) return; // Clear any existing timeout if (this._timeoutId) { clearTimeout(this._timeoutId); } // Reset error state this._hasError = false; this._copyWithFallback(this.text) .then(() => { this._isCopied = true; this._announcement = this.successLabel; this.dispatchEvent(new CustomEvent('copy', { detail: { text: this.text }, bubbles: true, composed: true })); this._timeoutId = window.setTimeout(() => { this._isCopied = false; this._announcement = ''; }, this.timeout); }) .catch((err: Error) => { this._hasError = true; this._announcement = this.errorLabel; this.dispatchEvent(new CustomEvent('copy-error', { detail: { error: err }, bubbles: true, composed: true })); this._timeoutId = window.setTimeout(() => { this._hasError = false; this._announcement = ''; }, this.timeout); }); } private _renderIcon() { // Render all three named slots stacked and toggle visibility via CSS for a smooth cross-fade return html` `; } render() { const currentLabel = this._hasError ? this.errorLabel : this._isCopied ? this.successLabel : this.label; return html` ${this._renderIcon()} ${this._announcement ? html` ${this._announcement} ` : nothing } `; } }