From da13af396875e3dbb2f7f9aac7ec000155aac991 Mon Sep 17 00:00:00 2001 From: lamborghini21 Date: Tue, 26 May 2026 20:31:12 +0100 Subject: [PATCH] feat: Add Quote Component with Gesture Support (#122) - Create Quote component with full gesture support (swipe, tap, pinch) - Integrate with existing GestureHandler infrastructure - Add copy to clipboard functionality with visual feedback - Support navigation arrows for carousel mode - Include author and source attribution - Add comprehensive test suite (19 tests) - Update component exports - Follow accessibility guidelines (ARIA labels, semantic HTML) - Support dark mode and responsive design --- src/components/index.ts | 1 + src/components/ui/Quote.tsx | 178 +++++++++++++++++++++ src/components/ui/__tests__/Quote.test.tsx | 170 ++++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 src/components/ui/Quote.tsx create mode 100644 src/components/ui/__tests__/Quote.test.tsx diff --git a/src/components/index.ts b/src/components/index.ts index 920aa254..b83c78dc 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ export * from './ui/Modal'; export * from './ui/Toast'; export * from './ui/EmptyState'; +export * from './ui/Quote'; export * from './shared/EnvGuard'; export * from './errors/ErrorBoundarySystem'; export { QRCodeComponent } from './QRCode'; diff --git a/src/components/ui/Quote.tsx b/src/components/ui/Quote.tsx new file mode 100644 index 00000000..d47173b5 --- /dev/null +++ b/src/components/ui/Quote.tsx @@ -0,0 +1,178 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { Quote as QuoteIcon, Copy, Check, ChevronLeft, ChevronRight } from 'lucide-react'; +import { GestureHandler } from '../mobile/GestureHandler'; + +export interface QuoteProps { + /** The quote text to display */ + text: string; + /** The author of the quote */ + author?: string; + /** Optional source or citation */ + source?: string; + /** Callback when quote is copied to clipboard */ + onCopy?: (text: string) => void; + /** Callback when swiped left (for carousel navigation) */ + onSwipeLeft?: () => void; + /** Callback when swiped right (for carousel navigation) */ + onSwipeRight?: () => void; + /** Callback when pinched in (zoom out) */ + onPinchIn?: () => void; + /** Callback when pinched out (zoom in) */ + onPinchOut?: () => void; + /** Additional class names */ + className?: string; + /** Whether to show navigation arrows (for carousel usage) */ + showNavigation?: boolean; + /** Whether to show copy button */ + showCopyButton?: boolean; + /** Custom quote icon */ + icon?: React.ReactNode; +} + +/** + * Quote Component with Gesture Support + * + * A reusable quote component that displays quotes with author attribution and gesture support. + * Supports swipe navigation, tap to copy, and pinch gestures for accessibility. + * + * @example + * ```tsx + * console.log('Copied!')} + * onSwipeLeft={() => console.log('Next quote')} + * /> + * ``` + */ +export const Quote: React.FC = ({ + text, + author, + source, + onCopy, + onSwipeLeft, + onSwipeRight, + onPinchIn, + onPinchOut, + className = '', + showNavigation = false, + showCopyButton = true, + icon, +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text); + setCopied(true); + onCopy?.(text); + + // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); + }, [text, onCopy]); + + const handleTap = useCallback(() => { + if (showCopyButton) { + handleCopy(); + } + }, [showCopyButton, handleCopy]); + + return ( + + {/* Navigation Left */} + {showNavigation && onSwipeLeft && ( + + )} + + {/* Quote Content */} +
+ {/* Quote Icon */} +
+ {icon || } +
+ + {/* Quote Text */} +
+

+ {text} +

+
+ + {/* Author and Source */} + {(author || source) && ( +
+ {author && ( + + — {author} + + )} + {source && ( + <> + + {source} + + )} +
+ )} +
+ + {/* Copy Button */} + {showCopyButton && ( + + )} + + {/* Navigation Right */} + {showNavigation && onSwipeRight && ( + + )} + + {/* Gesture Hint (visible on touch devices) */} +
+ Tap to copy • Swipe to navigate +
+
+ ); +}; diff --git a/src/components/ui/__tests__/Quote.test.tsx b/src/components/ui/__tests__/Quote.test.tsx new file mode 100644 index 00000000..97cb1c83 --- /dev/null +++ b/src/components/ui/__tests__/Quote.test.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Quote } from '../Quote'; + +describe('Quote Component', () => { + beforeEach(() => { + // Mock navigator.clipboard + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + it('renders the quote text', () => { + render(); + expect(screen.getByText('This is a test quote')).toBeInTheDocument(); + }); + + it('renders with author', () => { + render(); + expect(screen.getByText('— John Doe')).toBeInTheDocument(); + }); + + it('renders with source', () => { + render(); + expect(screen.getByText('Book Title')).toBeInTheDocument(); + }); + + it('renders with author and source', () => { + render(); + expect(screen.getByText('— John Doe')).toBeInTheDocument(); + expect(screen.getByText('Book Title')).toBeInTheDocument(); + }); + + it('renders copy button by default', () => { + render(); + const copyButton = screen.getByLabelText(/copy quote/i); + expect(copyButton).toBeInTheDocument(); + }); + + it('hides copy button when showCopyButton is false', () => { + render(); + const copyButton = screen.queryByLabelText(/copy quote/i); + expect(copyButton).not.toBeInTheDocument(); + }); + + it('copies text to clipboard when copy button is clicked', async () => { + const onCopy = vi.fn(); + render(); + + const copyButton = screen.getByLabelText(/copy quote/i); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test quote'); + expect(onCopy).toHaveBeenCalledWith('Test quote'); + }); + + it('shows check icon after copying', async () => { + render(); + + const copyButton = screen.getByLabelText(/copy quote/i); + fireEvent.click(copyButton); + + await waitFor(() => { + const checkIcon = screen.getByLabelText(/copied to clipboard/i); + expect(checkIcon).toBeInTheDocument(); + }); + }); + + it('resets copied state after 2 seconds', () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + render(); + + const copyButton = screen.getByLabelText(/copy quote/i); + fireEvent.click(copyButton); + + // Check icon should be present after click + const checkIcon = screen.getByLabelText(/copied to clipboard/i); + expect(checkIcon).toBeInTheDocument(); + + // Verify setTimeout was called with 2000ms + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + + setTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); + + it('calls onCopy callback when text is copied', () => { + const onCopy = vi.fn(); + render(); + + const copyButton = screen.getByLabelText(/copy quote/i); + fireEvent.click(copyButton); + + expect(onCopy).toHaveBeenCalledWith('Test quote'); + }); + + it('renders navigation arrows when showNavigation is true', () => { + render( + , + ); + + expect(screen.getByLabelText('Previous quote')).toBeInTheDocument(); + expect(screen.getByLabelText('Next quote')).toBeInTheDocument(); + }); + + it('calls onSwipeLeft when left navigation button is clicked', () => { + const onSwipeLeft = vi.fn(); + render(); + + const leftButton = screen.getByLabelText('Previous quote'); + fireEvent.click(leftButton); + + expect(onSwipeLeft).toHaveBeenCalledTimes(1); + }); + + it('calls onSwipeRight when right navigation button is clicked', () => { + const onSwipeRight = vi.fn(); + render(); + + const rightButton = screen.getByLabelText('Next quote'); + fireEvent.click(rightButton); + + expect(onSwipeRight).toHaveBeenCalledTimes(1); + }); + + it('does not render navigation arrows when showNavigation is false', () => { + render(); + + expect(screen.queryByLabelText('Previous quote')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Next quote')).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('renders with custom icon', () => { + const customIcon = ; + render(); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('has proper ARIA labels for accessibility', () => { + render(); + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('aria-label', 'Quote by John Doe'); + }); + + it('has proper ARIA label when author is missing', () => { + render(); + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('aria-label', 'Quote by Unknown author'); + }); + + it('renders with dark mode support', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('dark:from-purple-900/20'); + }); +});