All files / src/components/common TabContainer.tsx

100% Statements 10/10
100% Branches 18/18
100% Functions 6/6
100% Lines 9/9

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161                                                                                                                                              5x             84x         84x                         233x   233x       466x               8x 7x                                                             233x                                    
/**
 * TabContainer Component
 * 
 * A standardized, accessible tab component with full keyboard navigation support.
 * Implements ARIA tab patterns and consistent styling across the application.
 * 
 * @module components/common/TabContainer
 */
 
import React from 'react';
import { Tab } from '../../types/tabs';
import { useTabs } from '../../hooks/useTabs';
import { TRANSITIONS } from '../../constants/designTokens';
import { ARIA_ROLES } from '../../utils/accessibility';
 
/**
 * Props for TabContainer component
 */
export interface TabContainerProps {
  /** Array of tabs to render */
  tabs: Tab[];
  
  /** Initial active tab ID (defaults to first tab) */
  initialTab?: string;
  
  /** Callback when tab changes */
  onChange?: (tabId: string) => void;
  
  /** Optional CSS class for additional styling */
  className?: string;
  
  /** Test ID for testing purposes */
  testId?: string;
}
 
/**
 * TabContainer - Standardized tab navigation component
 * 
 * Features:
 * - Full keyboard navigation (Arrow Left/Right, Home, End)
 * - ARIA attributes for accessibility
 * - Support for icons and badges
 * - Disabled tab support
 * - Consistent visual styling with design tokens
 * 
 * @example
 * ```tsx
 * const tabs: Tab[] = [
 *   {
 *     id: 'overview',
 *     label: 'Overview',
 *     icon: <ChartIcon />,
 *     content: <OverviewPanel />,
 *     testId: 'overview-tab',
 *   },
 *   {
 *     id: 'details',
 *     label: 'Details',
 *     badge: '5',
 *     content: <DetailsPanel />,
 *   },
 * ];
 * 
 * <TabContainer
 *   tabs={tabs}
 *   initialTab="overview"
 *   onChange={(tabId) => console.log('Tab changed:', tabId)}
 *   testId="my-tabs"
 * />
 * ```
 */
export const TabContainer: React.FC<TabContainerProps> = ({
  tabs,
  initialTab,
  onChange,
  className = '',
  testId = 'tab-container',
}) => {
  const { activeTab, selectTab, handleKeyDown, tabRefs } = useTabs(tabs, {
    initialTab,
    onChange,
  });
 
  return (
    <div className={className} data-testid={testId}>
      {/* Tab List */}
      <div
        role={ARIA_ROLES.TABLIST}
        className="flex space-x-2 border-b border-gray-200 dark:border-gray-700 mb-6"
        data-testid={`${testId}-list`}
        aria-label="Tab navigation"
      >
        <span className="sr-only" id={`${testId}-keyboard-instructions`}>
          Use arrow keys to navigate between tabs. Press Enter or Space to activate a tab.
        </span>
        {tabs.map((tab) => {
          const isActive = activeTab === tab.id;
          
          return (
            <button
              key={tab.id}
              ref={(el) => {
                if (el) tabRefs.current.set(tab.id, el);
              }}
              role={ARIA_ROLES.TAB}
              aria-selected={isActive}
              aria-controls={`${testId}-panel-${tab.id}`}
              id={`${testId}-tab-${tab.id}`}
              tabIndex={isActive ? 0 : -1}
              disabled={tab.disabled}
              onClick={() => selectTab(tab.id)}
              onKeyDown={(e) => handleKeyDown(e, tab.id)}
              data-testid={tab.testId || `${testId}-tab-${tab.id}`}
              aria-describedby={`${testId}-keyboard-instructions`}
              className={`
                px-4 py-2 font-medium text-sm rounded-t-lg
                transition-colors
                focus:outline-none focus:ring-2 focus:ring-info focus:ring-offset-2
                ${isActive 
                  ? 'bg-white dark:bg-gray-800 text-info-dark dark:text-info-light border-b-2 border-info-dark dark:border-info-light' 
                  : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
                }
                ${tab.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
              `.trim()}
              style={{ transitionDuration: TRANSITIONS.fast }}
            >
              <div className="flex items-center gap-2">
                {tab.icon}
                <span>{tab.label}</span>
                {tab.badge && (
                  <span className="ml-2 px-2 py-0.5 text-xs bg-info-light/10 dark:bg-info-dark/20 text-info-dark dark:text-info-light rounded-full">
                    {tab.badge}
                  </span>
                )}
              </div>
            </button>
          );
        })}
      </div>
 
      {/* Tab Panels */}
      {tabs.map((tab) => (
        <div
          key={tab.id}
          role={ARIA_ROLES.TABPANEL}
          id={`${testId}-panel-${tab.id}`}
          aria-labelledby={`${testId}-tab-${tab.id}`}
          hidden={activeTab !== tab.id}
          data-testid={`${testId}-panel-${tab.id}`}
          className="focus:outline-none"
          tabIndex={activeTab === tab.id ? 0 : -1}
        >
          {activeTab === tab.id && tab.content}
        </div>
      ))}
    </div>
  );
};
 
export default TabContainer;