diff --git a/components/market/StatusBadge.tsx b/components/market/StatusBadge.tsx new file mode 100644 index 00000000..f16782ba --- /dev/null +++ b/components/market/StatusBadge.tsx @@ -0,0 +1,149 @@ +'use client'; + +import React from 'react'; +import { Clock, TrendingUp, Lock, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +/** + * Market status values representing the lifecycle of a prediction market. + * + * Transitions: + * OPEN → CLOSING_SOON → CLOSED → RESOLVED + * Any status → CANCELLED (at any point) + */ +export type MarketStatus = 'open' | 'closing_soon' | 'closed' | 'resolved' | 'cancelled'; + +interface StatusBadgeProps { + /** The current market status */ + status: MarketStatus; + /** Optional CSS class for additional styling */ + className?: string; + /** Whether to show tooltip on hover (default: true) */ + showTooltip?: boolean; +} + +/** + * Maps each market status to an icon component + */ +const STATUS_ICONS: Record = { + open: TrendingUp, + closing_soon: Clock, + closed: Lock, + resolved: CheckCircle2, + cancelled: AlertCircle, +}; + +/** + * Maps each market status to a display label + */ +const STATUS_LABELS: Record = { + open: 'Open', + closing_soon: 'Closing Soon', + closed: 'Closed', + resolved: 'Resolved', + cancelled: 'Cancelled', +}; + +/** + * Maps each market status to a human-readable tooltip description + * explaining what the status means and any transitions involved + */ +const STATUS_DESCRIPTIONS: Record = { + open: 'Market is accepting predictions. You can place or modify your prediction at any time while the market remains open.', + closing_soon: 'Market closes in under 1 hour. Place or finalize your prediction now before predictions are locked.', + closed: 'Predictions are locked. No new predictions or modifications are allowed. The market is awaiting resolution.', + resolved: 'Market has been resolved with an outcome. All predictions have been settled and payouts have been distributed.', + cancelled: 'Market was cancelled. All stakes have been refunded to their original owners. No predictions were settled.', +}; + +/** + * Maps each market status to a Badge variant for semantic styling + */ +const STATUS_VARIANTS: Record = { + open: 'success', + closing_soon: 'warning', + closed: 'info', + resolved: 'success', + cancelled: 'danger', +}; + +/** + * StatusBadge component for displaying market status transitions with live tooltips. + * + * Features: + * - Displays current market status with appropriate icon and color + * - Live tooltip showing status meaning and any transition information + * - Full screen reader support via sr-only text and ARIA attributes + * - Keyboard accessible (visible on focus as well as hover) + * - Responsive design that works on small screens + * - Dark mode support using Tailwind class-based strategy + * + * Accessibility (WCAG 2.1 AA): + * - Uses role="status" to announce status changes to screen readers + * - Includes aria-describedby linking to the sr-only description + * - SR-only text contains full tooltip description for accessibility + * - Keyboard accessible via focus (Radix Tooltip handles this) + * + * @example + * ```tsx + * // Basic usage + * + * + * // With custom styling + * + * + * // Without tooltip + * + * ``` + */ +export function StatusBadge({ + status, + className, + showTooltip = true, +}: StatusBadgeProps): JSX.Element { + const Icon = STATUS_ICONS[status]; + const label = STATUS_LABELS[status]; + const description = STATUS_DESCRIPTIONS[status]; + const variant = STATUS_VARIANTS[status]; + + const statusId = `status-description-${status}-${Math.random().toString(36).substr(2, 9)}`; + + const badge = ( + + + ); + + if (!showTooltip) { + return badge; + } + + return ( + + + {badge} + +
+

{label}

+

{description}

+
+
+
+
+ ); +} + +export default StatusBadge; diff --git a/components/market/__tests__/StatusBadge.test.tsx b/components/market/__tests__/StatusBadge.test.tsx new file mode 100644 index 00000000..bd8353ce --- /dev/null +++ b/components/market/__tests__/StatusBadge.test.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StatusBadge, type MarketStatus } from '../StatusBadge'; + +describe('StatusBadge', () => { + describe('Rendering', () => { + it('renders badge with correct status label', () => { + render(); + expect(screen.getByText('Open')).toBeInTheDocument(); + }); + + it('renders all status values without crashing', () => { + const statuses: MarketStatus[] = ['open', 'closing_soon', 'closed', 'resolved', 'cancelled']; + statuses.forEach((status) => { + const { unmount } = render(); + unmount(); + }); + }); + + it('renders with correct badge role', () => { + render(); + const badge = screen.getByRole('status'); + expect(badge).toBeInTheDocument(); + }); + + it('applies correct variant class for each status', () => { + const variants = { + open: 'success', + closing_soon: 'warning', + closed: 'info', + resolved: 'success', + cancelled: 'danger', + } as const; + + Object.entries(variants).forEach(([status, variant]) => { + const { unmount } = render(); + const badge = screen.getByRole('status'); + // Variant classes are applied through className, check that badge exists and has correct content + expect(badge).toHaveTextContent(status === 'closing_soon' ? 'Closing Soon' : status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ')); + unmount(); + }); + }); + + it('renders icon for each status', () => { + const statuses: MarketStatus[] = ['open', 'closing_soon', 'closed', 'resolved', 'cancelled']; + statuses.forEach((status) => { + const { container, unmount } = render(); + const icon = container.querySelector('svg'); + expect(icon).toBeInTheDocument(); + unmount(); + }); + }); + + it('hides icon from screen readers with aria-hidden', () => { + const { container } = render(); + const icon = container.querySelector('svg'); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + }); + }); + + describe('Screen Reader Support', () => { + it('includes sr-only description text in the DOM', () => { + render(); + const srText = screen.getByText(/Market is accepting predictions/); + expect(srText).toHaveClass('sr-only'); + }); + + it('links badge to sr-only description via aria-describedby', () => { + render(); + const badge = screen.getByRole('status'); + const descriptionId = badge.getAttribute('aria-describedby'); + expect(descriptionId).toBeTruthy(); + + const description = document.getElementById(descriptionId!); + expect(description).toBeInTheDocument(); + expect(description).toHaveClass('sr-only'); + }); + + it('provides correct sr-only text for each status', () => { + const descriptions = { + open: /Market is accepting predictions/, + closing_soon: /Market closes in under 1 hour/, + closed: /Predictions are locked/, + resolved: /Market has been resolved/, + cancelled: /Market was cancelled/, + }; + + Object.entries(descriptions).forEach(([status, regex]) => { + const { unmount } = render(); + expect(screen.getByText(regex)).toBeInTheDocument(); + unmount(); + }); + }); + + it('generates unique IDs for multiple instances to avoid conflicts', () => { + const { container } = render( + <> + + + + ); + + const badges = container.querySelectorAll('[role="status"]'); + const ids = Array.from(badges).map((b) => b.getAttribute('aria-describedby')); + + expect(ids[0]).not.toBe(ids[1]); + expect(ids[0]).toBeTruthy(); + expect(ids[1]).toBeTruthy(); + }); + }); + + describe('Tooltip Visibility', () => { + it('displays tooltip on hover when showTooltip is true', async () => { + const user = userEvent.setup(); + render(); + + const badge = screen.getByRole('status'); + await user.hover(badge); + + // Tooltip content should appear + const tooltipLabel = await screen.findByText('Open'); + const tooltipDescription = screen.getByText(/Market is accepting predictions/); + expect(tooltipLabel).toBeVisible(); + expect(tooltipDescription).toBeVisible(); + }); + + it('displays tooltip on keyboard focus', async () => { + const user = userEvent.setup(); + render(); + + const badge = screen.getByRole('status'); + badge.focus(); + await user.tab(); + + // Tooltip should be visible when badge has focus + const tooltipDescription = await screen.findByText(/Market is accepting predictions/, { + selector: 'body *:not(.sr-only)', + }); + expect(tooltipDescription).toBeInTheDocument(); + }); + + it('does not render tooltip when showTooltip is false', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const badge = screen.getByRole('status'); + await user.hover(badge); + + // Tooltip wrapper should not exist + const tooltipContent = container.querySelector('[role="tooltip"]'); + expect(tooltipContent).not.toBeInTheDocument(); + }); + + it('hides tooltip when mouse leaves', async () => { + const user = userEvent.setup(); + render(); + + const badge = screen.getByRole('status'); + await user.hover(badge); + + // Tooltip should be visible + let tooltipDescription = screen.getByText(/Market is accepting predictions/, { + selector: 'body *:not(.sr-only)', + }); + expect(tooltipDescription).toBeVisible(); + + // Move mouse away + await user.unhover(badge); + + // SR-only text should still exist but tooltip content should be hidden + tooltipDescription = screen.getByText(/Market is accepting predictions/); + expect(tooltipDescription).toHaveClass('sr-only'); + }); + }); + + describe('Tooltip Content', () => { + it('shows correct tooltip content for each status', async () => { + const user = userEvent.setup(); + const tooltipContents = { + open: /Market is accepting predictions/, + closing_soon: /Market closes in under 1 hour/, + closed: /Predictions are locked/, + resolved: /Market has been resolved/, + cancelled: /Market was cancelled/, + }; + + for (const [status, regex] of Object.entries(tooltipContents)) { + const { unmount } = render(); + const badge = screen.getByRole('status'); + await user.hover(badge); + + const content = screen.getByText(regex); + expect(content).toBeInTheDocument(); + unmount(); + } + }); + + it('includes status label in tooltip content', async () => { + const user = userEvent.setup(); + render(); + + const badge = screen.getByRole('status'); + await user.hover(badge); + + expect(screen.getByText('Closing Soon')).toBeInTheDocument(); + }); + }); + + describe('Styling and Customization', () => { + it('applies custom className', () => { + const { container } = render(); + const badge = screen.getByRole('status'); + expect(badge).toHaveClass('custom-class'); + }); + + it('applies default size to badge', () => { + const { container } = render(); + const badge = screen.getByRole('status'); + // Radix Badge with size="md" applies these classes + expect(badge).toHaveClass('px-2.5'); + }); + + it('renders compact badge with proper spacing', () => { + const { container } = render(); + const badge = container.querySelector('[role="status"]'); + expect(badge).toHaveClass('gap-1.5'); + }); + }); + + describe('Edge Cases', () => { + it('handles unknown status gracefully by rendering the provided status value', () => { + // This test documents current behavior; adjust as needed for your error handling + const { container } = render( + + ); + const badge = screen.getByRole('status'); + expect(badge).toBeInTheDocument(); + }); + + it('maintains accessibility when className is applied', async () => { + const user = userEvent.setup(); + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-describedby'); + + const descriptionId = badge.getAttribute('aria-describedby'); + const description = document.getElementById(descriptionId!); + expect(description).toBeInTheDocument(); + }); + + it('maintains proper semantics when showTooltip is false', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-describedby'); + + // SR text should still be present even without tooltip + const srText = screen.getByText(/Market is accepting predictions/); + expect(srText).toHaveClass('sr-only'); + }); + }); + + describe('Dark Mode', () => { + it('applies badge variant that supports dark mode', () => { + const { container } = render(); + const badge = screen.getByRole('status'); + + // Badge variants include dark mode support through Tailwind + // Just verify the badge renders and has the variant classes + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-'); + }); + }); + + describe('Responsive Behavior', () => { + it('renders correctly at small viewport', () => { + // Set a small viewport + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 320, + }); + + render(); + const badge = screen.getByRole('status'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('Open'); + }); + + it('renders correctly at large viewport', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1920, + }); + + render(); + const badge = screen.getByRole('status'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('Open'); + }); + }); + + describe('Transition Messages', () => { + it('communicates status transitions in tooltip text', async () => { + const user = userEvent.setup(); + + const transitions = [ + { status: 'open' as const, contains: 'place or modify' }, + { status: 'closing_soon' as const, contains: 'under 1 hour' }, + { status: 'closed' as const, contains: 'awaiting resolution' }, + { status: 'resolved' as const, contains: 'settled and payouts' }, + { status: 'cancelled' as const, contains: 'refunded' }, + ]; + + for (const { status, contains } of transitions) { + const { unmount } = render(); + const badge = screen.getByRole('status'); + await user.hover(badge); + + expect(screen.getByText(new RegExp(contains, 'i'))).toBeInTheDocument(); + unmount(); + } + }); + }); +});