Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions src/ui/components/copy-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import { OdyElement, classes, define } from '../base.js';
import { iconSvg } from '../icons.js';

/**
* `<ody-copy-button>` — a button that copies its `value` to the clipboard via
* `navigator.clipboard.writeText`, showing a transient success (or error) state.
* Dispatches a `copy` CustomEvent with `{ value, ok }`.
* `<ody-copy-button>` — a button that copies its `value` to the clipboard,
* showing a transient success (or error) state. Dispatches a `copy`
* CustomEvent with `{ value, ok }`.
*
* The copy uses a synchronous `document.execCommand('copy')` as the source of
* truth so it works inside cross-origin iframes (where the async Clipboard API
* is blocked by default) and legacy contexts. The async
* `navigator.clipboard.writeText` is used only as a best-effort enhancement
* when the synchronous path can't run.
*
* Attributes:
* - `value` — the text copied to the clipboard.
Expand Down Expand Up @@ -38,17 +44,59 @@ export class OdyCopyButton extends OdyElement {

readonly #onClick = (): void => {
const value = this.attr('value');
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (!clipboard || typeof clipboard.writeText !== 'function') {
this.#feedback('error', value);
return;

// The async Clipboard API is preferred where allowed, but it CANNOT be the
// source of truth: its promise settles after the user-gesture window, so a
// fallback attempted then would fail. Do the synchronous copy now (works in
// cross-origin iframes and legacy contexts), and only reach for the async
// API when the sync path couldn't run — its late result never changes the UI.
const ok = this.#execCopy(value);
if (!ok) {
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (clipboard && typeof clipboard.writeText === 'function') {
// Best-effort only; may still copy where execCommand is disabled but
// the async API is allowed. Its result never overrides the UI below.
void clipboard.writeText(value).then(
() => undefined,
() => undefined,
);
}
}
void clipboard.writeText(value).then(
() => this.#feedback('success', value),
() => this.#feedback('error', value),
);
this.#feedback(ok ? 'success' : 'error', value);
};

/**
* Synchronous clipboard write. Must be called within the user-gesture window
* (i.e. directly from the click handler, not from an async continuation).
* Works in cross-origin iframes and legacy contexts where the async Clipboard
* API is unavailable or blocked.
*/
#execCopy(value: string): boolean {
if (!value || typeof document === 'undefined') return false;
const el = document.createElement('textarea');
el.value = value;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.top = '0';
el.style.left = '0';
el.style.opacity = '0';
document.body.appendChild(el);
el.select();
try {
el.setSelectionRange(0, value.length);
} catch {
/* older browsers */
}
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
/* blocked */
}
document.body.removeChild(el);
return ok;
}

#feedback(state: 'success' | 'error', value: string): void {
this.#state = state;
this.render();
Expand Down
65 changes: 54 additions & 11 deletions test/ui/batch3b.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
vi.useRealTimers();
delete (document as unknown as { execCommand?: unknown }).execCommand;
});

describe('ody-accordion', () => {
Expand Down Expand Up @@ -109,46 +110,87 @@ describe('ody-tabs', () => {
});

describe('ody-copy-button', () => {
it('copies the value, shows success, dispatches copy, then reverts', async () => {
/** Stub the synchronous clipboard path (happy-dom has no execCommand). */
function stubExecCommand(result: boolean): ReturnType<typeof vi.fn> {
const execCommand = vi.fn().mockReturnValue(result);
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
return execCommand;
}

it('copies the value synchronously, shows success, dispatches copy, then reverts', async () => {
vi.useFakeTimers();
const execCommand = stubExecCommand(true);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const el = await mount<HTMLElement>('<ody-copy-button value="hello" label="Copy" success-duration="500"></ody-copy-button>');
const detail: Array<{ value: string; ok: boolean }> = [];
el.addEventListener('copy', (e) => detail.push((e as CustomEvent).detail));
el.querySelector('button')!.click();
await Promise.resolve();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith('hello');
expect(execCommand).toHaveBeenCalledWith('copy');
expect(el.querySelector('.ody-button.success')).not.toBeNull();
expect(detail).toEqual([{ value: 'hello', ok: true }]);
// The sync path succeeded, so the async API must not be touched.
expect(writeText).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(el.querySelector('.ody-button.success')).toBeNull();
});

it('shows an error state when the clipboard write rejects', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('nope'));
it.each([
['resolves', () => vi.fn().mockResolvedValue(undefined)],
['rejects', () => vi.fn().mockRejectedValue(new Error('blocked'))],
])('falls back to a best-effort async write when the sync copy fails and the async %s, reflecting the sync result immediately', async (_label, makeWriteText) => {
stubExecCommand(false);
const writeText = makeWriteText();
vi.stubGlobal('navigator', { clipboard: { writeText } });
const el = await mount<HTMLElement>('<ody-copy-button value="x"></ody-copy-button>');
const detail: Array<{ ok: boolean }> = [];
el.addEventListener('copy', (e) => detail.push((e as CustomEvent).detail));
el.querySelector('button')!.click();
await Promise.resolve();
await Promise.resolve();
// UI reflects the (failed) sync result immediately, without waiting on the async API.
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
expect(el.querySelector('.ody-button--icon-only')).not.toBeNull();
expect(detail[0].ok).toBe(false);
// Best-effort async path was still invoked.
expect(writeText).toHaveBeenCalledWith('x');
await Promise.resolve();
await Promise.resolve();
// The late async result (success or failure) never flips the UI back.
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
expect(detail).toHaveLength(1);
});

it('falls back to an error state when the clipboard API is missing', async () => {
it('shows an error state when both the sync copy and the async API are unavailable', async () => {
stubExecCommand(false);
vi.stubGlobal('navigator', {});
const el = await mount<HTMLElement>('<ody-copy-button value="x" label="C"></ody-copy-button>');
el.querySelector('button')!.click();
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
});

it('shows an error state for an empty value without running the sync copy', async () => {
const execCommand = stubExecCommand(true);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const el = await mount<HTMLElement>('<ody-copy-button label="C"></ody-copy-button>');
el.querySelector('button')!.click();
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
// Empty value short-circuits the sync copy before execCommand runs.
expect(execCommand).not.toHaveBeenCalled();
});

it('tolerates a throwing execCommand and reports an error', async () => {
(document as unknown as { execCommand: unknown }).execCommand = vi.fn(() => {
throw new Error('blocked');
});
vi.stubGlobal('navigator', {});
const el = await mount<HTMLElement>('<ody-copy-button value="x"></ody-copy-button>');
el.querySelector('button')!.click();
expect(el.querySelector('.ody-button.danger')).not.toBeNull();
});

it('clears a pending timer on a second copy and tolerates a non-numeric duration', async () => {
vi.stubGlobal('navigator', { clipboard: {} }); // no writeText -> error path, runs #feedback
stubExecCommand(false);
vi.stubGlobal('navigator', {}); // error path, runs #feedback
const el = await mount<HTMLElement>('<ody-copy-button value="x" success-duration="abc"></ody-copy-button>');
el.querySelector('button')!.click();
// second click while the first revert timer is still pending exercises clearTimeout
Expand All @@ -158,7 +200,8 @@ describe('ody-copy-button', () => {

it('skips the revert render after the element is disconnected', async () => {
vi.useFakeTimers();
vi.stubGlobal('navigator', { clipboard: {} });
stubExecCommand(false);
vi.stubGlobal('navigator', {});
const el = await mount<HTMLElement>('<ody-copy-button value="x" success-duration="100"></ody-copy-button>');
el.querySelector('button')!.click();
el.remove(); // now not connected; the pending timer must not throw on render
Expand Down
Loading