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
201 changes: 201 additions & 0 deletions src/components/BlockquoteCopyButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import BlockquoteCopyButton from './BlockquoteCopyButton';

// Mock clipboard API
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});

// Mock console methods
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

describe('BlockquoteCopyButton', () => {
let mockBlockquoteElement: HTMLElement;

beforeEach(() => {
// Create a mock blockquote element
mockBlockquoteElement = document.createElement('blockquote');
mockBlockquoteElement.innerHTML = '<p>Test content</p>';
document.body.appendChild(mockBlockquoteElement);

// Reset mocks
mockWriteText.mockClear();
consoleSpy.mockClear();
});

afterEach(() => {
// Clean up
document.body.removeChild(mockBlockquoteElement);
vi.clearAllTimers();
});

it('renders with default "Copy" text', () => {
render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

expect(screen.getByRole('button')).toHaveTextContent('Copy');
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Copy blockquote content');
});

it('copies text content when clicked', async () => {
mockWriteText.mockResolvedValueOnce(undefined);

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

fireEvent.click(screen.getByRole('button'));

await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('Test content');
});
});

it('copies markdown content when contentType is text/markdown', async () => {
mockWriteText.mockResolvedValueOnce(undefined);
mockBlockquoteElement.innerHTML = '<p><strong>Bold text</strong> and <em>italic text</em></p>';

render(
<BlockquoteCopyButton
blockquoteElement={mockBlockquoteElement}
contentType="text/markdown"
/>
);

fireEvent.click(screen.getByRole('button'));

await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('**Bold text** and *italic text*');
});
});

it('shows "Copied!" feedback after successful copy', async () => {
mockWriteText.mockResolvedValueOnce(undefined);

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
fireEvent.click(button);

// Wait for the state to change to "Copied!"
await waitFor(() => {
expect(button).toHaveTextContent('Copied!');
expect(button).toHaveClass('copied');
}, { timeout: 1000 });

expect(mockWriteText).toHaveBeenCalledWith('Test content');
});

it('shows "Error" feedback when copy fails', async () => {
const error = new Error('Clipboard not available');
mockWriteText.mockRejectedValueOnce(error);

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
fireEvent.click(button);

// Wait for the state to change to "Error"
await waitFor(() => {
expect(button).toHaveTextContent('Error');
expect(button).toHaveClass('error');
}, { timeout: 1000 });

expect(consoleSpy).toHaveBeenCalledWith('Failed to copy text: ', error);
expect(mockWriteText).toHaveBeenCalledWith('Test content');
});

it('removes existing copy buttons from cloned content', async () => {
mockWriteText.mockResolvedValueOnce(undefined);

// Add a copy button to the mock blockquote
const existingButton = document.createElement('button');
existingButton.className = 'blockquote-copy-button';
existingButton.textContent = 'Copy';
mockBlockquoteElement.appendChild(existingButton);

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

// Get all buttons and click the React-rendered one (should be the one with aria-label)
const reactButton = screen.getByLabelText('Copy blockquote content');
fireEvent.click(reactButton);

await waitFor(() => {
// Should copy only the text content, not including the button text
expect(mockWriteText).toHaveBeenCalledWith('Test content');
}, { timeout: 1000 });
});

it('prevents event propagation and default behavior', () => {
const mockEvent = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: document.createElement('button'),
};

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
fireEvent.click(button, mockEvent);

// Note: This test verifies the click handler exists and works
// The actual preventDefault/stopPropagation calls are tested indirectly
expect(button).toBeInTheDocument();
});

it('handles empty blockquote content', async () => {
mockWriteText.mockResolvedValueOnce(undefined);
mockBlockquoteElement.innerHTML = '';

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
fireEvent.click(button);

await vi.waitFor(() => expect(mockWriteText).toHaveBeenCalled());

expect(mockWriteText).toHaveBeenCalledWith('');
});

it('trims whitespace from copied content', async () => {
mockWriteText.mockResolvedValueOnce(undefined);
mockBlockquoteElement.innerHTML = ' <p> Test content </p> ';

render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
fireEvent.click(button);

await vi.waitFor(() => expect(mockWriteText).toHaveBeenCalled());

expect(mockWriteText).toHaveBeenCalledWith('Test content');
});

it('has correct accessibility attributes', () => {
render(
<BlockquoteCopyButton blockquoteElement={mockBlockquoteElement} />
);

const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('aria-label', 'Copy blockquote content');
});
});
82 changes: 82 additions & 0 deletions src/components/BlockquoteCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import TurndownService from 'turndown';

interface BlockquoteCopyButtonProps {
blockquoteElement: HTMLElement;
contentType?: string;
}

const BlockquoteCopyButton: React.FC<BlockquoteCopyButtonProps> = ({
blockquoteElement,
contentType
}) => {
const [buttonState, setButtonState] = useState<'default' | 'copied' | 'error'>('default');

// Create TurndownService instance
const turndownService = new TurndownService({ headingStyle: 'atx', emDelimiter: '*' });

const handleCopyClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();

try {
// Clone the blockquote to avoid modifying the live DOM
const clonedBlockquote = blockquoteElement.cloneNode(true) as HTMLElement;

// Remove any existing copy buttons from the clone
const buttonsInClone = clonedBlockquote.querySelectorAll('.blockquote-copy-button');
buttonsInClone.forEach(button => button.remove());

let textToCopy: string;

// Check if the content type is markdown
if (contentType === 'text/markdown') {
// Get the inner HTML of the clone (without the button)
const htmlContent = clonedBlockquote.innerHTML;
// Convert HTML to Markdown using Turndown
textToCopy = turndownService.turndown(htmlContent);
} else {
// Default behavior: Get text content from the clone
textToCopy = clonedBlockquote.textContent || '';
}

await navigator.clipboard.writeText(textToCopy.trim());

// Visual feedback: Change to copied state
setButtonState('copied');
setTimeout(() => {
setButtonState('default');
}, 1500);

} catch (err) {
console.error('Failed to copy text: ', err);

// Error feedback
setButtonState('error');
setTimeout(() => {
setButtonState('default');
}, 1500);
}
};

const getButtonText = () => {
switch (buttonState) {
case 'copied': return 'Copied!';
case 'error': return 'Error';
default: return 'Copy';
}
};

return (
<button
className={`blockquote-copy-button ${buttonState !== 'default' ? buttonState : ''}`}
onClick={handleCopyClick}
type="button"
aria-label="Copy blockquote content"
>
{getButtonText()}
</button>
);
};

export default BlockquoteCopyButton;
Loading