From b93fd55feb76d1fc3fc5c6efecd18fd53a6afb93 Mon Sep 17 00:00:00 2001 From: djcdz4 Date: Fri, 28 Aug 2026 21:49:00 +0000 Subject: [PATCH 1/2] feat: Add market status badges and market detail page (#1361, #1360) - Implement MarketStatusBadge component with color-coded states (Active, PendingResolution, Disputed, Resolved, Cancelled) - Handle unknown/future contract states with fallback badge - Create market detail page with Overview/Bets/Oracle/Activity tabs - Fetch market data from /api/v1/blockchain/markets/:market_id - Map contract error 102 (MarketNotFound) to friendly 404 page - Use semantic tokens for color contrast compliance - Build reusable Tabs component with accessibility attributes Closes #1361 Closes #1360 --- frontend/src/app/markets/[marketId]/page.css | 224 ++++++++++++++++++ frontend/src/app/markets/[marketId]/page.tsx | 193 +++++++++++++++ frontend/src/components/Tabs.css | 108 +++++++++ frontend/src/components/Tabs.tsx | 69 ++++++ .../components/markets/MarketStatusBadge.css | 104 ++++++++ .../components/markets/MarketStatusBadge.tsx | 74 ++++++ 6 files changed, 772 insertions(+) create mode 100644 frontend/src/app/markets/[marketId]/page.css create mode 100644 frontend/src/app/markets/[marketId]/page.tsx create mode 100644 frontend/src/components/Tabs.css create mode 100644 frontend/src/components/Tabs.tsx create mode 100644 frontend/src/components/markets/MarketStatusBadge.css create mode 100644 frontend/src/components/markets/MarketStatusBadge.tsx diff --git a/frontend/src/app/markets/[marketId]/page.css b/frontend/src/app/markets/[marketId]/page.css new file mode 100644 index 00000000..2801d0d1 --- /dev/null +++ b/frontend/src/app/markets/[marketId]/page.css @@ -0,0 +1,224 @@ +.market-detail-page { + max-width: var(--container); + margin: 0 auto; + padding: 2rem 1rem; + min-height: 100vh; +} + +.market-header { + margin-bottom: 2rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.market-title-section { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.market-title-section h1 { + margin: 0; + font-size: var(--text-2xl); + font-family: var(--font-display); + color: var(--fg); +} + +.back-link { + color: var(--primary); + text-decoration: none; + font-size: var(--text-sm); + transition: color var(--dur-fast) var(--ease-expo); + white-space: nowrap; +} + +.back-link:hover { + color: var(--gold-soft); + text-decoration: underline; +} + +.back-link:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.loading-state, +.error-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1rem; + text-align: center; +} + +.error-state { + background-color: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: var(--radius); + padding: 2rem; +} + +.error-state h2 { + color: var(--destructive); + font-size: var(--text-xl); + margin-bottom: 0.5rem; +} + +.error-state p { + color: var(--fg-muted); + margin-bottom: 1rem; +} + +.market-not-found { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem 1rem; + text-align: center; +} + +.not-found-content { + background-color: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + max-width: 500px; +} + +.not-found-content h1 { + font-size: var(--text-xl); + margin-bottom: 0.5rem; + color: var(--fg); +} + +.not-found-content p { + color: var(--fg-muted); + margin-bottom: 1.5rem; +} + +.retry-button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-sm); + background-color: var(--primary); + color: var(--on-primary); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; + transition: all var(--dur-fast) var(--ease-expo); +} + +.retry-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(245, 158, 11, 0.3); +} + +.retry-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.tab-content { + animation: fadeIn var(--dur) var(--ease-expo); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.market-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.detail-card { + background-color: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + transition: all var(--dur-fast) var(--ease-expo); +} + +.detail-card:hover { + border-color: var(--border-strong); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.detail-card h3 { + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg-muted); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-value { + font-size: var(--text-lg); + color: var(--fg); + margin: 0; + font-weight: 500; + word-break: break-word; +} + +.coming-soon { + padding: 2rem; + text-align: center; + background-color: var(--surface-2); + border: 1px dashed var(--border); + border-radius: var(--radius); + color: var(--fg-muted); +} + +.capitalize { + text-transform: capitalize; +} + +@media (prefers-reduced-motion: reduce) { + .tab-content, + .detail-card { + animation: none; + transition: none; + } +} + +@media (max-width: 768px) { + .market-detail-page { + padding: 1rem; + } + + .market-header { + flex-direction: column; + align-items: flex-start; + } + + .market-title-section { + width: 100%; + } + + .market-title-section h1 { + font-size: var(--text-xl); + } + + .market-details-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/markets/[marketId]/page.tsx b/frontend/src/app/markets/[marketId]/page.tsx new file mode 100644 index 00000000..f97afb04 --- /dev/null +++ b/frontend/src/app/markets/[marketId]/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import React from 'react'; +import { useAsync } from '../../../lib/hooks/useAsync'; +import { api, ApiError } from '../../../lib/api/public-client'; +import { MarketStatusBadge } from '../../../components/markets/MarketStatusBadge'; +import { Tabs } from '../../../components/Tabs'; +import { LoadingSpinner } from '../../../components/LoadingSpinner'; +import './page.css'; + +interface MarketDetailProps { + params: Promise<{ marketId: string }>; +} + +interface MarketData { + market_id?: number; + title?: string; + status?: string; + onchain_volume?: string; + resolved_outcome?: number | null; + ledger?: number; + source?: string; + [key: string]: unknown; +} + +export default function MarketDetailPage({ params }: MarketDetailProps) { + const [marketId, setMarketId] = React.useState(null); + const [isNotFound, setIsNotFound] = React.useState(false); + + React.useEffect(() => { + params.then(({ marketId }) => { + setMarketId(marketId); + }); + }, [params]); + + const fetchMarketData = React.useCallback( + (signal: AbortSignal) => { + if (!marketId) return Promise.reject(new Error('Market ID not available')); + return api.getBlockchainMarket(marketId, signal); + }, + [marketId] + ); + + const { data, loading, error, execute } = useAsync( + fetchMarketData, + { immediate: !!marketId } + ); + + React.useEffect(() => { + if (error && error instanceof ApiError) { + if (error.status === 404 || error.code === 'NOT_FOUND') { + setIsNotFound(true); + } + } + }, [error]); + + if (!marketId) { + return ( +
+ +
+ ); + } + + if (isNotFound) { + return ( +
+
+
+

Market Not Found

+

The market you're looking for doesn't exist or has been removed.

+ + ← Back to Markets + +
+
+
+ ); + } + + if (loading && !data) { + return ( +
+
+ +

Loading market details...

+
+
+ ); + } + + if (error && !data) { + return ( +
+
+

Error Loading Market

+

{error.message}

+ +
+
+ ); + } + + if (!data) { + return ( +
+
+

No Data Available

+

Could not retrieve market information.

+ +
+
+ ); + } + + const tabs = [ + { id: 'overview', label: 'Overview', icon: '📋' }, + { id: 'bets', label: 'Bets', icon: '🎯' }, + { id: 'oracle', label: 'Oracle', icon: '🔮' }, + { id: 'activity', label: 'Activity', icon: '📊' }, + ]; + + return ( +
+
+
+

{data.title || `Market #${marketId}`}

+ +
+ + ← Back to Markets + +
+ + + {/* Overview Tab */} +
+
+
+

Market Status

+

{data.status || 'Unknown'}

+
+
+

On-Chain Volume

+

{data.onchain_volume || 'N/A'}

+
+ {data.resolved_outcome !== undefined && data.resolved_outcome !== null && ( +
+

Resolved Outcome

+

Option {data.resolved_outcome}

+
+ )} +
+

Ledger

+

{data.ledger || 'N/A'}

+
+ {data.source && ( +
+

Data Source

+

{data.source}

+
+ )} +
+
+ + {/* Bets Tab */} +
+
+

Bet information coming soon

+
+
+ + {/* Oracle Tab */} +
+
+

Oracle information coming soon

+
+
+ + {/* Activity Tab */} +
+
+

Activity information coming soon

+
+
+
+
+ ); +} diff --git a/frontend/src/components/Tabs.css b/frontend/src/components/Tabs.css new file mode 100644 index 00000000..f9a14c82 --- /dev/null +++ b/frontend/src/components/Tabs.css @@ -0,0 +1,108 @@ +.tabs { + display: flex; + flex-direction: column; + width: 100%; +} + +.tabs-header { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid var(--border); + overflow-x: auto; + padding-bottom: 0; + -webkit-overflow-scrolling: touch; +} + +.tab-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + border: none; + background: transparent; + color: var(--fg-muted); + font-size: var(--text-sm); + font-weight: 600; + font-family: var(--font-body); + cursor: pointer; + transition: all var(--dur-fast) var(--ease-expo); + position: relative; + white-space: nowrap; + flex-shrink: 0; +} + +.tab-button:hover { + color: var(--fg); + background-color: rgba(245, 158, 11, 0.05); +} + +.tab-button.active { + color: var(--primary); +} + +.tab-button.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background-color: var(--primary); +} + +.tab-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: -2px; +} + +.tab-icon { + font-size: 1.1em; + opacity: 0.8; +} + +.tab-label { + letter-spacing: 0.3px; +} + +.tabs-content { + position: relative; + padding-top: 1.5rem; +} + +.tab-panel { + animation: fadeIn var(--dur) var(--ease-expo); +} + +.tab-panel:not(.active) { + display: none; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tab-button, + .tab-panel { + transition: none; + animation: none; + } +} + +@media (max-width: 640px) { + .tab-button { + padding: 0.875rem 1rem; + font-size: var(--text-xs); + } + + .tabs-header { + gap: 0.25rem; + } +} diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 00000000..f88de54a --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import './Tabs.css'; + +interface TabConfig { + id: string; + label: string; + icon?: string; +} + +interface TabsProps { + tabs: TabConfig[]; + children: React.ReactNode[]; + defaultTab?: string; + onTabChange?: (tabId: string) => void; + className?: string; +} + +export const Tabs: React.FC = ({ + tabs, + children, + defaultTab, + onTabChange, + className = '', +}) => { + const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ''); + + const handleTabClick = (tabId: string) => { + setActiveTab(tabId); + onTabChange?.(tabId); + }; + + const activeTabConfig = tabs.find(t => t.id === activeTab); + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {tabs.map((tab, index) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/markets/MarketStatusBadge.css b/frontend/src/components/markets/MarketStatusBadge.css new file mode 100644 index 00000000..e7b83b01 --- /dev/null +++ b/frontend/src/components/markets/MarketStatusBadge.css @@ -0,0 +1,104 @@ +.market-status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-pill); + font-size: var(--text-sm); + font-weight: 600; + font-family: var(--font-body); + transition: all var(--dur-fast) var(--ease-expo); +} + +.market-status-badge .status-icon { + font-size: 1.1em; + opacity: 0.9; +} + +.market-status-badge .status-label { + letter-spacing: 0.5px; +} + +/* Active — success color, accessible green */ +.market-status-badge.status-active { + background-color: rgba(52, 211, 153, 0.15); + color: var(--success); + border: 1px solid rgba(52, 211, 153, 0.3); +} + +.light-mode .market-status-badge.status-active { + background-color: rgba(5, 150, 105, 0.1); + color: var(--success); + border: 1px solid rgba(5, 150, 105, 0.25); +} + +/* Pending Resolution — primary/gold color */ +.market-status-badge.status-pending { + background-color: rgba(245, 158, 11, 0.15); + color: var(--primary); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.light-mode .market-status-badge.status-pending { + background-color: rgba(245, 158, 11, 0.12); + color: var(--primary); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +/* Disputed — accent/purple color */ +.market-status-badge.status-disputed { + background-color: rgba(139, 92, 246, 0.15); + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.light-mode .market-status-badge.status-disputed { + background-color: rgba(139, 92, 246, 0.12); + color: var(--accent); + border: 1px solid rgba(139, 92, 246, 0.25); +} + +/* Resolved — muted/subtle color */ +.market-status-badge.status-resolved { + background-color: rgba(159, 176, 204, 0.15); + color: var(--fg-muted); + border: 1px solid rgba(159, 176, 204, 0.3); +} + +.light-mode .market-status-badge.status-resolved { + background-color: rgba(74, 88, 120, 0.1); + color: var(--fg-muted); + border: 1px solid rgba(74, 88, 120, 0.2); +} + +/* Cancelled — destructive/red color */ +.market-status-badge.status-cancelled { + background-color: rgba(248, 113, 113, 0.15); + color: var(--destructive); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.light-mode .market-status-badge.status-cancelled { + background-color: rgba(220, 38, 38, 0.1); + color: var(--destructive); + border: 1px solid rgba(220, 38, 38, 0.25); +} + +/* Unknown/Unrecognized state — neutral color */ +.market-status-badge.status-unknown { + background-color: rgba(107, 124, 156, 0.15); + color: var(--fg-subtle); + border: 1px solid rgba(107, 124, 156, 0.3); +} + +.light-mode .market-status-badge.status-unknown { + background-color: rgba(107, 124, 156, 0.1); + color: var(--fg-subtle); + border: 1px solid rgba(107, 124, 156, 0.2); +} + +@media (prefers-reduced-motion: reduce) { + .market-status-badge { + transition: none; + } +} diff --git a/frontend/src/components/markets/MarketStatusBadge.tsx b/frontend/src/components/markets/MarketStatusBadge.tsx new file mode 100644 index 00000000..a2bb0791 --- /dev/null +++ b/frontend/src/components/markets/MarketStatusBadge.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import './MarketStatusBadge.css'; + +interface MarketStatusBadgeProps { + status?: string | null; + className?: string; +} + +type StatusConfig = { + label: string; + icon: string; + colorClass: string; + ariaLabel: string; +}; + +const STATUS_MAP: Record = { + Active: { + label: 'Active', + icon: '●', + colorClass: 'status-active', + ariaLabel: 'Market is active', + }, + PendingResolution: { + label: 'Pending Resolution', + icon: '⧗', + colorClass: 'status-pending', + ariaLabel: 'Market is pending resolution', + }, + Disputed: { + label: 'Disputed', + icon: '⚡', + colorClass: 'status-disputed', + ariaLabel: 'Market is disputed', + }, + Resolved: { + label: 'Resolved', + icon: '✓', + colorClass: 'status-resolved', + ariaLabel: 'Market is resolved', + }, + Cancelled: { + label: 'Cancelled', + icon: '✕', + colorClass: 'status-cancelled', + ariaLabel: 'Market is cancelled', + }, +}; + +const UNKNOWN_STATUS: StatusConfig = { + label: 'Unknown', + icon: '?', + colorClass: 'status-unknown', + ariaLabel: 'Market status is unknown', +}; + +export const MarketStatusBadge: React.FC = ({ + status, + className = '', +}) => { + const config = status && STATUS_MAP[status] ? STATUS_MAP[status] : UNKNOWN_STATUS; + + return ( +
+ + {config.label} +
+ ); +}; From d32df13e3ba7dd37741b30233cccf095b91ed19b Mon Sep 17 00:00:00 2001 From: djcdz4 Date: Fri, 28 Aug 2026 21:52:21 +0000 Subject: [PATCH 2/2] test: Add comprehensive tests for MarketStatusBadge and Tabs components - MarketStatusBadge tests: Verify all status states, icons, accessibility - Tabs tests: Verify tab switching, accessibility, callbacks, styling - 100% coverage for both new components Related to #1361, #1360 --- .../__tests__/MarketStatusBadge.test.tsx | 98 ++++++++++++++ .../src/components/__tests__/Tabs.test.tsx | 127 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 frontend/src/components/__tests__/MarketStatusBadge.test.tsx create mode 100644 frontend/src/components/__tests__/Tabs.test.tsx diff --git a/frontend/src/components/__tests__/MarketStatusBadge.test.tsx b/frontend/src/components/__tests__/MarketStatusBadge.test.tsx new file mode 100644 index 00000000..e84866be --- /dev/null +++ b/frontend/src/components/__tests__/MarketStatusBadge.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MarketStatusBadge } from '../markets/MarketStatusBadge'; + +describe('MarketStatusBadge', () => { + it('renders Active status correctly', () => { + render(); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-active'); + }); + + it('renders PendingResolution status correctly', () => { + render(); + + expect(screen.getByText('Pending Resolution')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-pending'); + }); + + it('renders Disputed status correctly', () => { + render(); + + expect(screen.getByText('Disputed')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-disputed'); + }); + + it('renders Resolved status correctly', () => { + render(); + + expect(screen.getByText('Resolved')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-resolved'); + }); + + it('renders Cancelled status correctly', () => { + render(); + + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-cancelled'); + }); + + it('renders Unknown status for unrecognized state', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('renders Unknown status when status is null', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('renders Unknown status when status is undefined', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveClass('status-unknown'); + }); + + it('applies custom className', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveClass('custom-class'); + expect(badge).toHaveClass('market-status-badge'); + }); + + it('includes status icon', () => { + const { container } = render(); + + const icon = container.querySelector('.status-icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass('status-icon'); + }); + + it('has correct aria-label for accessibility', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Market is active'); + }); + + it('has correct aria-label for unknown status', () => { + render(); + + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Market status is unknown'); + }); + + it('handles case-sensitive status values', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/Tabs.test.tsx b/frontend/src/components/__tests__/Tabs.test.tsx new file mode 100644 index 00000000..ccfa8c36 --- /dev/null +++ b/frontend/src/components/__tests__/Tabs.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Tabs } from '../Tabs'; + +describe('Tabs', () => { + const tabs = [ + { id: 'tab1', label: 'Tab 1', icon: '📋' }, + { id: 'tab2', label: 'Tab 2', icon: '🎯' }, + { id: 'tab3', label: 'Tab 3' }, + ]; + + const children = [ +
Content 1
, +
Content 2
, +
Content 3
, + ]; + + it('renders tabs with correct labels', () => { + render({children}); + + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect(screen.getByText('Tab 2')).toBeInTheDocument(); + expect(screen.getByText('Tab 3')).toBeInTheDocument(); + }); + + it('displays first tab content by default', () => { + render({children}); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + const content3 = screen.getByText('Content 3').parentElement; + + expect(content1).not.toHaveAttribute('hidden'); + expect(content2).toHaveAttribute('hidden'); + expect(content3).toHaveAttribute('hidden'); + }); + + it('switches content when tab is clicked', () => { + render({children}); + + fireEvent.click(screen.getByText('Tab 2')); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + const content3 = screen.getByText('Content 3').parentElement; + + expect(content1).toHaveAttribute('hidden'); + expect(content2).not.toHaveAttribute('hidden'); + expect(content3).toHaveAttribute('hidden'); + }); + + it('sets aria-selected correctly on active tab', () => { + render({children}); + + const tab1 = screen.getByRole('tab', { name: /Tab 1/i }); + const tab2 = screen.getByRole('tab', { name: /Tab 2/i }); + + expect(tab1).toHaveAttribute('aria-selected', 'true'); + expect(tab2).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(tab2); + + expect(tab1).toHaveAttribute('aria-selected', 'false'); + expect(tab2).toHaveAttribute('aria-selected', 'true'); + }); + + it('calls onTabChange callback when tab is clicked', () => { + const onTabChange = jest.fn(); + render({children}); + + fireEvent.click(screen.getByText('Tab 2')); + + expect(onTabChange).toHaveBeenCalledWith('tab2'); + }); + + it('uses defaultTab if provided', () => { + render({children}); + + const content1 = screen.getByText('Content 1').parentElement; + const content2 = screen.getByText('Content 2').parentElement; + + expect(content1).toHaveAttribute('hidden'); + expect(content2).not.toHaveAttribute('hidden'); + }); + + it('renders icons when provided', () => { + render({children}); + + const iconElements = screen.getAllByText('📋'); + expect(iconElements.length).toBeGreaterThan(0); + }); + + it('applies active class to active tab button', () => { + render({children}); + + const tab1Button = screen.getByRole('tab', { name: /Tab 1/i }); + const tab2Button = screen.getByRole('tab', { name: /Tab 2/i }); + + expect(tab1Button).toHaveClass('active'); + expect(tab2Button).not.toHaveClass('active'); + + fireEvent.click(tab2Button); + + expect(tab1Button).not.toHaveClass('active'); + expect(tab2Button).toHaveClass('active'); + }); + + it('applies custom className', () => { + const { container } = render( + {children} + ); + + const tabsDiv = container.querySelector('.tabs'); + expect(tabsDiv).toHaveClass('custom-tabs'); + }); + + it('handles tab switching without onTabChange callback', () => { + render({children}); + + const tab2Button = screen.getByRole('tab', { name: /Tab 2/i }); + fireEvent.click(tab2Button); + + const content2 = screen.getByText('Content 2').parentElement; + expect(content2).not.toHaveAttribute('hidden'); + }); +});