diff --git a/src/web/.eslintrc.ts b/src/web/.eslintrc.ts index 467cacc5e6..7518132320 100644 --- a/src/web/.eslintrc.ts +++ b/src/web/.eslintrc.ts @@ -15,7 +15,8 @@ module.exports = { ecmaFeatures: { jsx: true, }, - project: './tsconfig.json', + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, }, env: { browser: true, @@ -28,7 +29,9 @@ module.exports = { version: 'detect', }, 'import/resolver': { - typescript: {}, + typescript: { + project: './tsconfig.json', + }, }, }, extends: [ @@ -41,19 +44,17 @@ module.exports = { 'plugin:import/warnings', 'plugin:import/typescript', ], - plugins: [ - '@typescript-eslint', - 'react', - 'react-hooks', - 'import', - ], + plugins: ['@typescript-eslint', 'react', 'react-hooks', 'import'], rules: { // TypeScript specific rules '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + }, + ], '@typescript-eslint/strict-boolean-expressions': 'error', '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', @@ -62,9 +63,12 @@ module.exports = { '@typescript-eslint/prefer-nullish-coalescing': 'error', '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/no-non-null-assertion': 'error', - '@typescript-eslint/consistent-type-imports': ['error', { - prefer: 'type-imports', - }], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + }, + ], // React specific rules 'react/react-in-jsx-scope': 'off', @@ -84,20 +88,16 @@ module.exports = { 'react-hooks/exhaustive-deps': 'warn', // Import/Export rules - 'import/order': ['error', { - groups: [ - 'builtin', - 'external', - 'internal', - 'parent', - 'sibling', - 'index', - ], - 'newlines-between': 'always', - alphabetize: { - order: 'asc', + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + }, }, - }], + ], 'import/no-unresolved': 'error', 'import/no-cycle': 'error', 'import/no-unused-modules': 'error', @@ -108,23 +108,29 @@ module.exports = { 'import/no-useless-path-segments': 'error', // General code style rules - 'no-console': ['warn', { - allow: ['warn', 'error'], - }], + 'no-console': [ + 'warn', + { + allow: ['warn', 'error'], + }, + ], 'no-debugger': 'error', 'no-alert': 'error', 'no-var': 'error', 'prefer-const': 'error', 'prefer-template': 'error', - 'eqeqeq': ['error', 'always'], - 'curly': ['error', 'all'], - 'max-len': ['error', { - code: 100, - ignoreUrls: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - ignoreRegExpLiterals: true, - }], + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'max-len': [ + 'error', + { + code: 100, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + }, + ], }, overrides: [ { @@ -138,4 +144,4 @@ module.exports = { }, }, ], -}; \ No newline at end of file +}; diff --git a/src/web/eslint.config.js b/src/web/eslint.config.js new file mode 100644 index 0000000000..9a19897c8d --- /dev/null +++ b/src/web/eslint.config.js @@ -0,0 +1,100 @@ +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import importPlugin from 'eslint-plugin-import'; + +export default tseslint.config( + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: ['**/node_modules/**', 'build/**', 'dist/**'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...globals.browser, + ...globals.node, + ...globals.jest, + }, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + import: importPlugin, + }, + rules: { + // TypeScript rules + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + + // Import rules + 'import/no-unresolved': 'error', + 'import/named': 'error', + 'import/namespace': 'error', + 'import/default': 'error', + 'import/export': 'error', + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + pathGroups: [ + { + pattern: '@/**', + group: 'internal', + }, + ], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + }, + }, + ], + 'import/no-cycle': 'error', + 'import/no-self-import': 'error', + 'import/no-useless-path-segments': 'error', + + // React rules + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + + // General rules + 'no-console': ['warn', { allow: ['error'] }], + 'no-debugger': 'error', + 'no-alert': 'error', + 'max-len': [ + 'error', + { + code: 100, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + }, + ], + }, + }, + eslintConfigPrettier +); diff --git a/src/web/package.json b/src/web/package.json index e656ce15ce..4d1c4066ad 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -22,7 +22,6 @@ "@fontsource/inter": "^4.5.0", "@mui/icons-material": "^5.0.0", "@mui/material": "^5.0.0", - "@react-aria/i18n": "^3.0.0", "@react-aria/interactions": "^3.15.0", "@reduxjs/toolkit": "^1.9.5", "@sentry/browser": "^8.50.0", @@ -36,6 +35,7 @@ "deepmerge": "^4.3.1", "file-saver": "^2.0.5", "lodash": "^4.17.21", + "node-cache": "^5.1.2", "process": "^0.11.10", "react": "^18.2.0", "react-chartjs-2": "5.0.0", @@ -55,19 +55,25 @@ "@babel/preset-env": "^7.26.0", "@jest/globals": "^29.3.1", "@jest/types": "^29.0.0", + "@react-aria/i18n": "^3.12.5", + "@react-aria/live-announcer": "^3.4.1", "@segment/analytics-next": "^1.76.1", "@sentry/react": "^8.50.0", + "@sentry/replay": "^7.116.0", "@sentry/tracing": "^7.120.3", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.0", "@types/crypto-js": "^4.2.2", + "@types/file-saver": "^2.0.7", "@types/gapi": "0.0.44", "@types/lodash": "^4.17.14", "@types/react-dom": "^19.0.3", "@types/sanitize-html": "^2.13.0", "@types/segment-analytics": "^0.0.38", + "@types/styled-components": "^5.1.34", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", "@vitejs/plugin-react": "^4.0.0", @@ -80,6 +86,7 @@ "eslint-plugin-import": "^2.27.0", "eslint-plugin-react": "^7.32.0", "eslint-plugin-react-hooks": "^4.6.0", + "focus-trap-react": "^11.0.3", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "jest-performance": "^1.0.0", @@ -89,6 +96,7 @@ "ts-jest": "^29.0.5", "typescript": "^4.9.0", "typescript-eslint": "^8.21.0", + "validator": "^13.12.0", "vite": "^4.0.0", "vite-tsconfig-paths": "^4.0.0", "vitest": "^0.34.0", diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index 98be9fedca..57411ec86b 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -16,6 +16,7 @@ import Layout from './components/layout/Layout'; import ProtectedRoute from './components/auth/ProtectedRoute'; import LoadingSpinner from './components/common/LoadingSpinner'; import store from './store'; +import { useAuth } from './hooks/useAuth'; // Lazy-loaded route components const Login = React.lazy(() => import('./pages/Login')); @@ -24,6 +25,7 @@ const Benchmarks = React.lazy(() => import('./pages/Benchmarks')); const CompanyMetrics = React.lazy(() => import('./pages/CompanyMetrics')); const Reports = React.lazy(() => import('./pages/Reports')); const Settings = React.lazy(() => import('./pages/Settings')); +const NotFound = React.lazy(() => import('./pages/NotFound')); // Constants const ROUTES = { @@ -103,6 +105,8 @@ const LoadingFallback: React.FC = () => ( * Root Application Component */ const App: React.FC = () => { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + // Error handler for route loading failures const handleError = useCallback((error: Error) => { console.error('Application error:', error); @@ -112,6 +116,11 @@ const App: React.FC = () => { }); }, []); + // Show loading state while checking authentication + if (authLoading) { + return ; + } + return ( { } /> {/* Protected Routes */} + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Default Route */} - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - + isAuthenticated ? ( + + ) : ( + + ) } /> - {/* Redirects */} - } /> - } /> + {/* 404 Route */} + } /> diff --git a/src/web/src/components/auth/ProtectedRoute.tsx b/src/web/src/components/auth/ProtectedRoute.tsx index cb07c0f14c..1023ca52c4 100644 --- a/src/web/src/components/auth/ProtectedRoute.tsx +++ b/src/web/src/components/auth/ProtectedRoute.tsx @@ -6,7 +6,7 @@ */ import React, { FC, PropsWithChildren, useEffect, memo } from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; import LoadingSpinner from '../common/LoadingSpinner'; import { theme } from '../../config/theme'; @@ -23,7 +23,7 @@ export enum UserRole { USER = 'USER', ANALYST = 'ANALYST', ADMIN = 'ADMIN', - SYSTEM = 'SYSTEM' + SYSTEM = 'SYSTEM', } /** @@ -34,7 +34,7 @@ interface ProtectedRouteProps { * Optional array of roles that are permitted to access the route */ allowedRoles?: string[]; - + /** * Optional custom redirect path for unauthorized access */ @@ -47,7 +47,10 @@ interface ProtectedRouteProps { * @param allowedRoles - Array of permitted roles * @returns boolean indicating if user has permission */ -const validateUserRole = (userRole: string | undefined, allowedRoles: string[] | undefined): boolean => { +const validateUserRole = ( + userRole: string | undefined, + allowedRoles: string[] | undefined +): boolean => { if (!allowedRoles || allowedRoles.length === 0) return true; if (!userRole) return false; return allowedRoles.includes(userRole); @@ -57,83 +60,60 @@ const validateUserRole = (userRole: string | undefined, allowedRoles: string[] | * Protected Route Component * Implements route protection with authentication and authorization checks */ -const ProtectedRoute: FC> = memo(({ - children, - allowedRoles, - redirectPath = DEFAULT_REDIRECT -}) => { - const { - isAuthenticated, - isLoading, - user, - validateSession - } = useAuth(); +const ProtectedRoute: FC = memo( + ({ children, allowedRoles, redirectPath = DEFAULT_REDIRECT }) => { + const { isAuthenticated, isLoading, user, validateSession } = useAuth(); + + // Validate session on mount and when authentication state changes + useEffect(() => { + if (isAuthenticated) { + validateSession(); + } + }, [isAuthenticated, validateSession]); - // Validate session on mount and when authentication state changes - useEffect(() => { - if (isAuthenticated) { - validateSession(); + // Show loading spinner while authenticating + if (isLoading) { + return ( +
+ +
+ ); } - }, [isAuthenticated, validateSession]); - // Show loading spinner while authenticating - if (isLoading) { - return ( -
- -
- ); - } + // Redirect to login if not authenticated + if (!isAuthenticated) { + return ; + } - // Redirect to login if not authenticated - if (!isAuthenticated) { - return ( - - ); - } + // Check role-based access if roles are specified + if (allowedRoles && allowedRoles.length > 0) { + const hasPermission = validateUserRole(user?.role, allowedRoles); - // Check role-based access if roles are specified - if (allowedRoles && allowedRoles.length > 0) { - const hasPermission = validateUserRole(user?.role, allowedRoles); - - if (!hasPermission) { - return ( - - ); + if (!hasPermission) { + return ; + } } - } - // Render protected content - return ( - - {children} - - ); -}); + // Render protected content + return ; + } +); // Display name for debugging ProtectedRoute.displayName = 'ProtectedRoute'; -export default ProtectedRoute; \ No newline at end of file +export default ProtectedRoute; diff --git a/src/web/src/components/charts/AreaChart.tsx b/src/web/src/components/charts/AreaChart.tsx index c75d32bf73..8bf981a431 100644 --- a/src/web/src/components/charts/AreaChart.tsx +++ b/src/web/src/components/charts/AreaChart.tsx @@ -1,9 +1,33 @@ import React, { useMemo } from 'react'; -import { Line } from 'react-chartjs-2'; // react-chartjs-2@5.0.0 -import { Chart as ChartJS } from 'chart.js/auto'; // chart.js@4.0.0 -import { chartColors } from '../../config/chart'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + ChartOptions, + ChartData, + LinearScale, + CategoryScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, +} from 'chart.js'; +import { CHART_COLORS } from '../../config/chart'; import { generateChartOptions } from '../../utils/chartHelpers'; +// Register required Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); + // Default height for the area chart if not specified const DEFAULT_HEIGHT = 300; @@ -11,12 +35,11 @@ const DEFAULT_HEIGHT = 300; interface IAreaChartProps { data: number[]; labels: string[]; - title: string; - height?: number; - fillArea?: boolean; - ariaLabel: string; - onDataPointClick?: (index: number, value: number) => void; - isLoading?: boolean; + title?: string; + height?: string; + loading?: boolean; + className?: string; + ariaLabel?: string; } /** @@ -24,135 +47,84 @@ interface IAreaChartProps { * @param props - Component props of type IAreaChartProps * @returns Rendered area chart component */ -const AreaChart: React.FC = React.memo(({ - data, - labels, - title, - height = DEFAULT_HEIGHT, - fillArea = true, - ariaLabel, - onDataPointClick, - isLoading = false -}) => { - // Memoize chart options for performance - const chartOptions = useMemo(() => { - return generateChartOptions('line', { - onClick: (event: any, elements: any[]) => { - if (elements.length > 0 && onDataPointClick) { - const index = elements[0].index; - onDataPointClick(index, data[index]); - } - }, - plugins: { - title: { - display: true, - text: title, - font: { - size: 16, - weight: 'bold' - } - }, - accessibility: { - enabled: true, - description: ariaLabel - } - } - }, { - announceOnRender: true, - description: ariaLabel - }); - }, [title, ariaLabel, onDataPointClick, data]); - - // Memoize chart data configuration - const chartData = useMemo(() => ({ +const AreaChart: React.FC = React.memo( + ({ + data, labels, - datasets: [{ - label: title, - data: data, - fill: fillArea, - backgroundColor: `${chartColors.primary}40`, - borderColor: chartColors.primary, - tension: 0.4, - pointRadius: 4, - pointHoverRadius: 6, - pointBackgroundColor: chartColors.primary, - pointBorderColor: '#ffffff', - pointBorderWidth: 2, - 'aria-label': `${title} data points`, - role: 'graphics-symbol' - }] - }), [data, labels, title, fillArea]); - - // Error boundary wrapper for resilient rendering - const renderChart = () => { - try { - if (isLoading) { - return ( -
- Loading chart data... -
- ); - } + title, + height = '400px', + loading = false, + className, + ariaLabel = 'Area chart', + }) => { + const chartData: ChartData<'line'> = useMemo( + () => ({ + labels, + datasets: [ + { + label: title || 'Data', + data, + fill: true, + backgroundColor: 'rgba(21, 30, 45, 0.1)', + borderColor: '#151e2d', + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + }, + ], + }), + [data, labels, title] + ); - if (!data.length || !labels.length) { - return ( -
- No data available -
- ); - } + const options: ChartOptions<'line'> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: !!title, + position: 'top' as const, + }, + tooltip: { + enabled: true, + mode: 'index' as const, + intersect: false, + }, + }, + scales: { + x: { + display: true, + grid: { + display: false, + }, + }, + y: { + display: true, + beginAtZero: true, + }, + }, + interaction: { + mode: 'nearest' as const, + axis: 'x' as const, + intersect: false, + }, + }), + [title] + ); - return ( -
- { - const ctx = chart.canvas.getContext('2d'); - if (ctx) { - ctx.save(); - ctx.globalCompositeOperation = 'destination-over'; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, chart.width, chart.height); - ctx.restore(); - } - } - }]} - /> -
- ); - } catch (error) { - console.error('Error rendering area chart:', error); - return ( -
- Error loading chart -
- ); + if (loading) { + return
Loading chart...
; } - }; - return renderChart(); -}); + return ( +
+ +
+ ); + } +); // Display name for debugging AreaChart.displayName = 'AreaChart'; -export default AreaChart; \ No newline at end of file +export default AreaChart; diff --git a/src/web/src/components/charts/BarChart.tsx b/src/web/src/components/charts/BarChart.tsx index 3917235cbd..b9736273b9 100644 --- a/src/web/src/components/charts/BarChart.tsx +++ b/src/web/src/components/charts/BarChart.tsx @@ -1,7 +1,7 @@ import React, { useRef, useEffect, useCallback } from 'react'; import { Chart, ChartData, ChartOptions } from 'chart.js/auto'; // chart.js@4.0.0 import { useDebounce } from 'use-debounce'; // use-debounce@9.0.0 -import { chartColors } from '../../config/chart'; +import { CHART_COLORS } from '../../config/chart'; import { generateChartOptions } from '../../utils/chartHelpers'; // Enhanced interface for bar chart data points @@ -20,178 +20,185 @@ interface IBarChartProps { const CHART_DEFAULT_HEIGHT = 300; // Memoized bar chart component for performance optimization -const BarChart: React.FC = React.memo(({ - data, - labels, - height = CHART_DEFAULT_HEIGHT, - width, - className = '', - onBarClick, - ariaLabel, - highContrastMode = false -}) => { - // Chart instance reference - const chartRef = useRef(null); - const canvasRef = useRef(null); - - // Debounced resize handler for performance - const [debouncedResize] = useDebounce(() => { - if (chartRef.current) { - chartRef.current.resize(); - } - }, 250); - - // Cleanup chart instance on unmount - useEffect(() => { - return () => { +const BarChart: React.FC = React.memo( + ({ + data, + labels, + height = CHART_DEFAULT_HEIGHT, + width, + className = '', + onBarClick, + ariaLabel, + highContrastMode = false, + }) => { + // Chart instance reference + const chartRef = useRef(null); + const canvasRef = useRef(null); + + // Debounced resize handler for performance + const [debouncedResize] = useDebounce(() => { if (chartRef.current) { - chartRef.current.destroy(); + chartRef.current.resize(); } - }; - }, []); - - // Initialize chart with accessibility features - const initializeChart = useCallback(() => { - if (!canvasRef.current) return; - - // Configure chart data with WCAG-compliant colors - const chartData: ChartData = { - labels, - datasets: [{ - data: data.map(d => d.value), - backgroundColor: highContrastMode ? - chartColors.highContrast.background : - `${chartColors.primary}CC`, - borderColor: highContrastMode ? - chartColors.highContrast.border : - chartColors.primary, - borderWidth: 1, - borderRadius: 4, - barThickness: 'flex', - maxBarThickness: 64, - minBarLength: 4, - 'aria-label': `${ariaLabel} data series`, - role: 'graphics-symbol' - }] - }; - - // Enhanced chart options with accessibility support - const options = generateChartOptions('bar', { - onClick: (event, elements) => { - if (onBarClick && elements.length > 0) { - const index = elements[0].index; - onBarClick(index, data[index].value); + }, 250); + + // Cleanup chart instance on unmount + useEffect(() => { + return () => { + if (chartRef.current) { + chartRef.current.destroy(); } - }, - plugins: { - accessibility: { - enabled: true, - announceOnRender: true, - description: ariaLabel + }; + }, []); + + // Initialize chart with accessibility features + const initializeChart = useCallback(() => { + if (!canvasRef.current) return; + + // Configure chart data with WCAG-compliant colors + const chartData: ChartData = { + labels, + datasets: [ + { + data: data.map((d) => d.value), + backgroundColor: highContrastMode + ? CHART_COLORS.highContrast.background + : `${CHART_COLORS.primary}CC`, + borderColor: highContrastMode ? CHART_COLORS.highContrast.border : CHART_COLORS.primary, + borderWidth: 1, + borderRadius: 4, + barThickness: 'flex', + maxBarThickness: 64, + minBarLength: 4, + 'aria-label': `${ariaLabel} data series`, + role: 'graphics-symbol', + }, + ], + }; + + // Enhanced chart options with accessibility support + const options = generateChartOptions('bar', { + onClick: (event, elements) => { + if (onBarClick && elements.length > 0) { + const index = elements[0].index; + onBarClick(index, data[index].value); + } }, - tooltip: { - enabled: true, - backgroundColor: highContrastMode ? - chartColors.highContrast.tooltip : - `${chartColors.primary}E6`, - titleFont: { - family: 'Inter', - size: 14, - weight: 'bold' + plugins: { + accessibility: { + enabled: true, + announceOnRender: true, + description: ariaLabel, }, - bodyFont: { - family: 'Inter', - size: 13 + tooltip: { + enabled: true, + backgroundColor: highContrastMode + ? CHART_COLORS.highContrast.tooltip + : `${CHART_COLORS.primary}E6`, + titleFont: { + family: 'Inter', + size: 14, + weight: 'bold', + }, + bodyFont: { + family: 'Inter', + size: 13, + }, + padding: 12, + cornerRadius: 4, + callbacks: { + label: (context) => { + const value = context.raw as number; + return `Value: ${value.toLocaleString()}`; + }, + }, }, - padding: 12, - cornerRadius: 4, - callbacks: { - label: (context) => { - const value = context.raw as number; - return `Value: ${value.toLocaleString()}`; - } - } - } - } - }); + }, + }); - // Initialize chart with error boundary - try { - if (chartRef.current) { - chartRef.current.destroy(); - } + // Initialize chart with error boundary + try { + if (chartRef.current) { + chartRef.current.destroy(); + } - chartRef.current = new Chart(canvasRef.current, { - type: 'bar', - data: chartData, - options - }); - } catch (error) { - console.error('Error initializing chart:', error); - // Implement error boundary fallback UI if needed - } - }, [data, labels, onBarClick, ariaLabel, highContrastMode]); - - // Handle chart updates - useEffect(() => { - initializeChart(); - }, [initializeChart]); - - // Handle resize events - useEffect(() => { - window.addEventListener('resize', debouncedResize); - return () => { - window.removeEventListener('resize', debouncedResize); - }; - }, [debouncedResize]); - - // Setup keyboard navigation - const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - if (!chartRef.current) return; - - const key = event.key; - const currentIndex = chartRef.current.getActiveElements()[0]?.index ?? -1; - - switch (key) { - case 'ArrowRight': - case 'ArrowLeft': { - event.preventDefault(); - const direction = key === 'ArrowRight' ? 1 : -1; - const newIndex = Math.max(0, Math.min(data.length - 1, currentIndex + direction)); - chartRef.current.setActiveElements([{ - datasetIndex: 0, - index: newIndex - }]); - chartRef.current.update(); - break; + chartRef.current = new Chart(canvasRef.current, { + type: 'bar', + data: chartData, + options, + }); + } catch (error) { + console.error('Error initializing chart:', error); + // Implement error boundary fallback UI if needed } - case 'Enter': - case ' ': { - event.preventDefault(); - if (currentIndex >= 0 && onBarClick) { - onBarClick(currentIndex, data[currentIndex].value); + }, [data, labels, onBarClick, ariaLabel, highContrastMode]); + + // Handle chart updates + useEffect(() => { + initializeChart(); + }, [initializeChart]); + + // Handle resize events + useEffect(() => { + window.addEventListener('resize', debouncedResize); + return () => { + window.removeEventListener('resize', debouncedResize); + }; + }, [debouncedResize]); + + // Setup keyboard navigation + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!chartRef.current) return; + + const key = event.key; + const currentIndex = chartRef.current.getActiveElements()[0]?.index ?? -1; + + switch (key) { + case 'ArrowRight': + case 'ArrowLeft': { + event.preventDefault(); + const direction = key === 'ArrowRight' ? 1 : -1; + const newIndex = Math.max(0, Math.min(data.length - 1, currentIndex + direction)); + chartRef.current.setActiveElements([ + { + datasetIndex: 0, + index: newIndex, + }, + ]); + chartRef.current.update(); + break; + } + case 'Enter': + case ' ': { + event.preventDefault(); + if (currentIndex >= 0 && onBarClick) { + onBarClick(currentIndex, data[currentIndex].value); + } + break; + } } - break; - } - } - }, [data, onBarClick]); - - return ( -
- -
- ); -}); + }, + [data, onBarClick] + ); + + return ( +
+ +
+ ); + } +); BarChart.displayName = 'BarChart'; -export default BarChart; \ No newline at end of file +export default BarChart; diff --git a/src/web/src/components/charts/BenchmarkChart.tsx b/src/web/src/components/charts/BenchmarkChart.tsx index d4a62b346e..9999fefb5f 100644 --- a/src/web/src/components/charts/BenchmarkChart.tsx +++ b/src/web/src/components/charts/BenchmarkChart.tsx @@ -1,10 +1,21 @@ -import React, { useEffect, useRef, useCallback, memo } from 'react'; -import { Chart, ChartData } from 'chart.js/auto'; // chart.js@4.0.0 -import debounce from 'lodash/debounce'; - +import React, { useRef, useEffect, useMemo } from 'react'; +import { + Chart as ChartJS, + ChartOptions, + ChartData, + LinearScale, + CategoryScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; import { IBenchmark } from '../../interfaces/IBenchmark'; -import { benchmarkChartOptions } from '../../config/chart'; -import { prepareBenchmarkData } from '../../utils/chartHelpers'; +import { formatMetricValue } from '../../utils/chartHelpers'; + +// Register required Chart.js components +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); /** * Props interface for the BenchmarkChart component with comprehensive validation @@ -27,157 +38,138 @@ interface BenchmarkChartProps { * Implements WCAG 2.1 compliance and performance best practices * @version 1.0.0 */ -const BenchmarkChart: React.FC = memo(({ +const BenchmarkChart: React.FC = ({ benchmark, companyMetric, height = '400px', - className = '', - ariaLabel + className, + ariaLabel = 'Benchmark comparison chart', }) => { - // Refs for chart instance and canvas element - const chartRef = useRef(null); - const chartInstance = useRef(null); - const resizeObserver = useRef(null); - - /** - * Initializes the chart with accessibility features and optimized rendering - */ - const initializeChart = useCallback(() => { - if (!chartRef.current) return; + const canvasRef = useRef(null); + const chartRef = useRef(null); + + const chartData: ChartData<'line'> = useMemo( + () => ({ + labels: benchmark.percentiles.map((p) => `${p}th percentile`), + datasets: [ + { + label: 'Industry Benchmark', + data: benchmark.values, + borderColor: '#151e2d', + backgroundColor: 'rgba(21, 30, 45, 0.1)', + fill: true, + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + }, + ...(companyMetric + ? [ + { + label: 'Your Company', + data: new Array(benchmark.percentiles.length).fill(companyMetric), + borderColor: '#46608C', + borderDash: [5, 5], + fill: false, + tension: 0, + }, + ] + : []), + ], + }), + [benchmark, companyMetric] + ); - const ctx = chartRef.current.getContext('2d'); - if (!ctx) return; + const options: ChartOptions<'line'> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top' as const, + }, + tooltip: { + mode: 'index' as const, + intersect: false, + callbacks: { + label: (context) => { + const value = context.raw as number; + return `${context.dataset.label}: ${formatMetricValue(value, benchmark.valueType)}`; + }, + }, + }, + }, + scales: { + x: { + display: true, + title: { + display: true, + text: 'Percentile', + }, + }, + y: { + display: true, + title: { + display: true, + text: benchmark.name, + }, + }, + }, + interaction: { + mode: 'nearest' as const, + axis: 'x' as const, + intersect: false, + }, + }), + [benchmark] + ); - // Prepare initial chart data with accessibility metadata - const chartData = prepareBenchmarkData(benchmark, { - description: ariaLabel || `Benchmark comparison chart for ${benchmark.metric.name}` - }); + useEffect(() => { + if (!canvasRef.current) return; - // Add company metric overlay if provided - if (typeof companyMetric === 'number') { - chartData.datasets.push({ - label: 'Your Company', - data: Array(5).fill(companyMetric), - borderColor: '#168947', - backgroundColor: '#16894720', - borderWidth: 2, - borderDash: [5, 5], - fill: false, - 'aria-label': `Your company's ${benchmark.metric.name} value`, - role: 'graphics-symbol' + if (!chartRef.current) { + chartRef.current = new ChartJS(canvasRef.current, { + type: 'line', + data: chartData, + options, }); + } else { + chartRef.current.data = chartData; + chartRef.current.options = options; + chartRef.current.update(); } - // Initialize chart with accessibility and performance optimizations - chartInstance.current = new Chart(ctx, { - type: 'bar', - data: chartData as ChartData, - options: { - ...benchmarkChartOptions, - plugins: { - ...benchmarkChartOptions.plugins, - accessibility: { - enabled: true, - announceOnRender: true, - description: chartData.metadata.description - } - } + return () => { + if (chartRef.current) { + chartRef.current.destroy(); + chartRef.current = null; } - }); - }, [benchmark, companyMetric, ariaLabel]); + }; + }, [chartData, options]); - /** - * Updates chart data with debouncing for performance - */ - const updateChart = useCallback(debounce(() => { - if (!chartInstance.current) return; + useEffect(() => { + const chart = chartRef.current; + const canvas = canvasRef.current; + if (!chart || !canvas) return; - const chartData = prepareBenchmarkData(benchmark, { - description: ariaLabel || `Benchmark comparison chart for ${benchmark.metric.name}` + const resizeObserver = new ResizeObserver(() => { + chart.resize(); }); - if (typeof companyMetric === 'number') { - chartData.datasets.push({ - label: 'Your Company', - data: Array(5).fill(companyMetric), - borderColor: '#168947', - backgroundColor: '#16894720', - borderWidth: 2, - borderDash: [5, 5], - fill: false, - 'aria-label': `Your company's ${benchmark.metric.name} value`, - role: 'graphics-symbol' - }); - } - - chartInstance.current.data = chartData as ChartData; - chartInstance.current.update('none'); // Use 'none' mode for performance - }, 150), [benchmark, companyMetric, ariaLabel]); + resizeObserver.observe(canvas); - /** - * Handles responsive resizing with performance optimization - */ - const handleResize = useCallback(() => { - if (chartInstance.current) { - chartInstance.current.resize(); - } - }, []); - - // Initialize chart and setup resize observer - useEffect(() => { - initializeChart(); - - // Setup resize observer for responsive behavior - resizeObserver.current = new ResizeObserver(handleResize); - if (chartRef.current) { - resizeObserver.current.observe(chartRef.current); - } - - // Cleanup function return () => { - if (chartInstance.current) { - chartInstance.current.destroy(); - chartInstance.current = null; - } - if (resizeObserver.current) { - resizeObserver.current.disconnect(); - resizeObserver.current = null; - } + resizeObserver.disconnect(); }; - }, [initializeChart, handleResize]); - - // Update chart when data changes - useEffect(() => { - updateChart(); - }, [benchmark, companyMetric, updateChart]); - - // Keyboard navigation handler for accessibility - const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - // Trigger focus on chart element - chartRef.current?.focus(); - } }, []); return ( -
- +
+
); -}); +}; // Display name for debugging BenchmarkChart.displayName = 'BenchmarkChart'; -export default BenchmarkChart; \ No newline at end of file +export default React.memo(BenchmarkChart); diff --git a/src/web/src/components/charts/LineChart.tsx b/src/web/src/components/charts/LineChart.tsx index 16b807d385..3c83ca35d6 100644 --- a/src/web/src/components/charts/LineChart.tsx +++ b/src/web/src/components/charts/LineChart.tsx @@ -1,158 +1,129 @@ -import React, { useRef, useCallback, useEffect } from 'react'; +import React, { useRef, useCallback, useEffect, useMemo } from 'react'; +import { + Chart as ChartJS, + ChartOptions, + ChartData, + LinearScale, + CategoryScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; import { Line } from 'react-chartjs-2'; // react-chartjs-2@5.0.0 -import { Chart as ChartJS, ChartOptions } from 'chart.js/auto'; // chart.js@4.0.0 -import { chartColors } from '../../config/chart'; +import { MetricValueType } from '../../interfaces/IMetric'; import { generateChartOptions, formatMetricValue } from '../../utils/chartHelpers'; +// Register required Chart.js components +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + // Default chart height in pixels const DEFAULT_HEIGHT = 300; // Interface for chart data points -interface DataPoint { - x: string | number; - y: number; +export interface DataPoint { + value: number; + label: string; } // Props interface with comprehensive accessibility and customization options interface ILineChartProps { data: DataPoint[]; - labels: string[]; - metricType: 'percentage' | 'currency' | 'number'; - height?: number; - options?: Partial; + metricType: MetricValueType; + height?: string; + title?: string; + className?: string; ariaLabel?: string; locale?: string; - isRTL?: boolean; - onError?: (error: Error) => void; } /** * A reusable, accessible line chart component for metric visualization * Built with Chart.js and optimized for performance and accessibility */ -const LineChart: React.FC = React.memo(({ - data, - labels, - metricType, - height = DEFAULT_HEIGHT, - options = {}, - ariaLabel, - locale = 'en-US', - isRTL = false, - onError -}) => { - // Chart instance reference for cleanup - const chartRef = useRef(null); - - // Memoized chart data preparation - const getChartData = useCallback(() => { - return { - labels, - datasets: [{ - label: ariaLabel || 'Metric trend', - data: data.map(point => point.y), - fill: false, - borderColor: chartColors.primary, - backgroundColor: chartColors.background, - borderWidth: 2, - pointBackgroundColor: chartColors.accent, - pointHoverBackgroundColor: chartColors.secondary, - pointHoverRadius: 6, - pointHitRadius: 8, - tension: 0.4, - 'aria-label': `${ariaLabel || 'Metric'} data points`, - role: 'graphics-symbol' - }] - }; - }, [data, labels, ariaLabel]); - - // Memoized chart options with accessibility enhancements - const getEnhancedOptions = useCallback(() => { - const baseOptions = generateChartOptions('line', options, { - announceOnRender: true, - description: ariaLabel - }); - - return { - ...baseOptions, - layout: { - ...baseOptions.layout, - rtl: isRTL, - }, - plugins: { - ...baseOptions.plugins, - tooltip: { - ...baseOptions.plugins?.tooltip, - callbacks: { - label: (context: any) => { - const value = context.raw as number; - return formatMetricValue(value, metricType, { locale }); - } - } - } - }, - scales: { - ...baseOptions.scales, - y: { - ...baseOptions.scales?.y, - position: isRTL ? 'right' : 'left', - ticks: { - callback: (value: number) => formatMetricValue(value, metricType, { locale }) - } - } - } - }; - }, [options, ariaLabel, isRTL, metricType, locale]); - - // Cleanup chart instance on unmount - useEffect(() => { - return () => { - if (chartRef.current) { - chartRef.current.destroy(); - } - }; - }, []); - - // Error boundary handler - const handleError = (error: Error) => { - console.error('LineChart error:', error); - onError?.(error); - }; +const LineChart: React.FC = React.memo( + ({ + data, + metricType, + height = '400px', + title, + className, + ariaLabel = 'Line chart', + locale = 'en-US', + }) => { + const chartData: ChartData<'line'> = useMemo( + () => ({ + labels: data.map((point) => point.label), + datasets: [ + { + label: title || 'Data', + data: data.map((point) => point.value), + borderColor: '#151e2d', + backgroundColor: 'rgba(21, 30, 45, 0.1)', + fill: false, + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + }, + ], + }), + [data, title] + ); - try { - return ( -
- - Chart data visualization is not available. - Please check your browser compatibility or try again later. -

- } - /> -
+ const options: ChartOptions<'line'> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: !!title, + position: 'top' as const, + }, + tooltip: { + mode: 'index' as const, + intersect: false, + callbacks: { + label: (context) => { + const value = context.raw as number; + return `${context.dataset.label}: ${formatMetricValue(value, metricType, locale)}`; + }, + }, + }, + }, + scales: { + x: { + display: true, + grid: { + display: false, + }, + }, + y: { + display: true, + beginAtZero: true, + ticks: { + callback: (value) => formatMetricValue(value as number, metricType, locale), + }, + }, + }, + interaction: { + mode: 'nearest' as const, + axis: 'x' as const, + intersect: false, + }, + }), + [metricType, title, locale] ); - } catch (error) { - handleError(error as Error); + return ( -
-

Unable to display chart. Please try again later.

+
+
); } -}); +); // Display name for debugging LineChart.displayName = 'LineChart'; -export default LineChart; \ No newline at end of file +export default LineChart; diff --git a/src/web/src/components/charts/MetricTrendChart.tsx b/src/web/src/components/charts/MetricTrendChart.tsx index 25b2168c80..2fe9b051dd 100644 --- a/src/web/src/components/charts/MetricTrendChart.tsx +++ b/src/web/src/components/charts/MetricTrendChart.tsx @@ -1,10 +1,33 @@ import React, { useMemo, useCallback, useRef, useEffect } from 'react'; +import { + Chart as ChartJS, + ChartOptions, + ChartData, + LinearScale, + CategoryScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, +} from 'chart.js'; import { Line } from 'react-chartjs-2'; // react-chartjs-2@4.0.0 -import { Chart as ChartJS, ChartOptions } from 'chart.js/auto'; // chart.js@4.0.0 -import { metricTrendOptions } from '../../config/chart'; -import { generateChartOptions, formatMetricValue } from '../../utils/chartHelpers'; -import { calculateGrowthRate } from '../../utils/metricCalculators'; import { MetricValueType } from '../../interfaces/IMetric'; +import { formatMetricValue } from '../../utils/chartHelpers'; +import { calculateGrowthRate } from '../../utils/metricCalculators'; + +// Register required Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); // Enhanced interface for metric trend data points interface MetricDataPoint { @@ -16,11 +39,11 @@ interface MetricDataPoint { interface IMetricTrendChartProps { data: MetricDataPoint[]; metricType: MetricValueType; - height?: number; + height?: string; showGrowthRate?: boolean; isRTL?: boolean; locale?: string; - accessibilityLabel?: string; + ariaLabel?: string; } // Worker for performance-optimized data processing @@ -34,19 +57,15 @@ const dataProcessingWorker = new Worker( * @param locale - Locale for formatting * @param isRTL - RTL layout flag */ -const prepareChartData = ( - data: MetricDataPoint[], - locale: string, - isRTL: boolean -) => { +const prepareChartData = (data: MetricDataPoint[], locale: string, isRTL: boolean) => { // Sort data chronologically const sortedData = [...data].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); // Format dates according to locale - const labels = sortedData.map(point => + const labels = sortedData.map((point) => new Intl.DateTimeFormat(locale, { month: 'short', - year: 'numeric' + year: 'numeric', }).format(point.timestamp) ); @@ -58,18 +77,18 @@ const prepareChartData = ( return { labels, - datasets: [{ - label: 'Metric Value', - data: sortedData.map(point => point.value), - borderColor: metricTrendOptions.plugins?.legend?.labels?.color || '#151e2d', - backgroundColor: 'rgba(21, 30, 45, 0.1)', - fill: true, - tension: 0.4, - pointRadius: 4, - pointHoverRadius: 6, - 'aria-label': 'Metric trend line', - role: 'graphics-symbol' - }] + datasets: [ + { + label: 'Metric Value', + data: sortedData.map((point) => point.value), + borderColor: '#151e2d', + backgroundColor: 'rgba(21, 30, 45, 0.1)', + fill: true, + tension: 0.4, + pointRadius: 4, + pointHoverRadius: 6, + }, + ], }; }; @@ -79,87 +98,127 @@ const prepareChartData = ( const MetricTrendChart: React.FC = ({ data, metricType, - height = 300, + height = '400px', showGrowthRate = false, isRTL = false, locale = 'en-US', - accessibilityLabel + ariaLabel = 'Metric trend chart', }) => { - const chartRef = useRef(null); + const canvasRef = useRef(null); + const chartRef = useRef(null); // Memoized chart data preparation - const chartData = useMemo(() => - prepareChartData(data, locale, isRTL), + const chartData: ChartData<'line'> = useMemo( + () => prepareChartData(data, locale, isRTL), [data, locale, isRTL] ); // Memoized chart options with accessibility enhancements - const chartOptions = useMemo(() => { - const options: ChartOptions = generateChartOptions('line', metricTrendOptions, { - announceOnRender: true, - description: accessibilityLabel || 'Metric trend visualization' - }); - - // Configure RTL-aware tooltips - options.plugins = { - ...options.plugins, - tooltip: { - ...options.plugins?.tooltip, - position: isRTL ? 'nearest' : 'average', - callbacks: { - label: (context) => { - const value = context.raw as number; - return `${context.dataset.label}: ${formatMetricValue(value, metricType)}`; - } - } - } - }; + const options: ChartOptions<'line'> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top' as const, + }, + tooltip: { + mode: 'index' as const, + intersect: false, + position: isRTL ? 'nearest' : 'average', + callbacks: { + label: (context) => { + const value = context.raw as number; + return `${context.dataset.label}: ${formatMetricValue(value, metricType, locale)}`; + }, + }, + }, + }, + scales: { + x: { + display: true, + reverse: isRTL, + grid: { + display: false, + }, + ticks: { + align: isRTL ? 'end' : 'center', + }, + }, + y: { + display: true, + position: isRTL ? 'right' : 'left', + beginAtZero: true, + ticks: { + callback: (value) => formatMetricValue(value as number, metricType, locale), + }, + }, + }, + interaction: { + mode: 'nearest' as const, + axis: 'x' as const, + intersect: false, + }, + }), + [metricType, isRTL, locale] + ); + + useEffect(() => { + if (!canvasRef.current) return; + + if (!chartRef.current) { + chartRef.current = new ChartJS(canvasRef.current, { + type: 'line', + data: chartData, + options, + }); + } else { + chartRef.current.data = chartData; + chartRef.current.options = options; + chartRef.current.update(); + } - // Configure RTL-aware scales - options.scales = { - ...options.scales, - x: { - ...options.scales?.x, - reverse: isRTL, - ticks: { - ...options.scales?.x?.ticks, - align: isRTL ? 'end' : 'center' - } + return () => { + if (chartRef.current) { + chartRef.current.destroy(); + chartRef.current = null; } }; - - return options; - }, [metricType, isRTL, accessibilityLabel]); + }, [chartData, options]); // Handle keyboard navigation - const handleKeyboardNavigation = useCallback((event: KeyboardEvent) => { - if (!chartRef.current) return; + const handleKeyboardNavigation = useCallback( + (event: KeyboardEvent) => { + if (!chartRef.current) return; - const chart = chartRef.current; - const activeElements = chart.getActiveElements(); + const chart = chartRef.current; + const activeElements = chart.getActiveElements(); - if (activeElements.length === 0) { - chart.setActiveElements([{ datasetIndex: 0, index: 0 }]); - return; - } + if (activeElements.length === 0) { + chart.setActiveElements([{ datasetIndex: 0, index: 0 }]); + return; + } - const currentIndex = activeElements[0].index; - let newIndex = currentIndex; + const currentIndex = activeElements[0].index; + let newIndex = currentIndex; - switch (event.key) { - case 'ArrowRight': - newIndex = isRTL ? currentIndex - 1 : currentIndex + 1; - break; - case 'ArrowLeft': - newIndex = isRTL ? currentIndex + 1 : currentIndex - 1; - break; - } + switch (event.key) { + case 'ArrowRight': + newIndex = isRTL ? currentIndex - 1 : currentIndex + 1; + break; + case 'ArrowLeft': + newIndex = isRTL ? currentIndex + 1 : currentIndex - 1; + break; + } - if (newIndex >= 0 && newIndex < data.length) { - chart.setActiveElements([{ datasetIndex: 0, index: newIndex }]); - chart.update(); - } - }, [data.length, isRTL]); + if (newIndex >= 0 && newIndex < data.length) { + chart.setActiveElements([{ datasetIndex: 0, index: newIndex }]); + chart.update(); + } + }, + [data.length, isRTL] + ); // Set up keyboard navigation listeners useEffect(() => { @@ -168,51 +227,34 @@ const MetricTrendChart: React.FC = ({ }, [handleKeyboardNavigation]); // Calculate and display growth rate if enabled - const growthRateDisplay = useMemo(() => { + const growthRate = useMemo(() => { if (!showGrowthRate || data.length < 2) return null; const latestValue = data[data.length - 1].value; const previousValue = data[data.length - 2].value; - + try { const growth = calculateGrowthRate(latestValue, previousValue, { valueType: metricType, - validationRules: { precision: 1 } + validationRules: { precision: 1 }, } as any); - return ( -
- {formatMetricValue(growth, 'percentage')} growth -
- ); + return formatMetricValue(growth, 'percentage', locale); } catch { return null; } - }, [data, showGrowthRate, metricType]); + }, [data, showGrowthRate, metricType, locale]); return ( -
- { - const ctx = chart.ctx; - ctx.save(); - ctx.textAlign = isRTL ? 'right' : 'left'; - ctx.restore(); - } - }]} - /> - {growthRateDisplay} +
+ + {growthRate && ( +
+ {growthRate} growth +
+ )}
); }; -export default MetricTrendChart; \ No newline at end of file +export default React.memo(MetricTrendChart); diff --git a/src/web/src/components/common/Input.tsx b/src/web/src/components/common/Input.tsx index 6f71bcf4c5..502584e98e 100644 --- a/src/web/src/components/common/Input.tsx +++ b/src/web/src/components/common/Input.tsx @@ -1,13 +1,13 @@ import React, { useRef, useEffect } from 'react'; -import styled from '@emotion/styled'; -import { useFormContext } from 'react-hook-form'; +import styled from 'styled-components'; +import { useFormContext, RegisterOptions, UseFormRegister, FieldValues } from 'react-hook-form'; import '../../styles/variables.css'; // Types type InputType = 'text' | 'number' | 'email' | 'tel' | 'password' | 'search' | 'url'; type InputMode = 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; -export interface InputProps { +export interface InputProps extends Omit, 'size'> { name: string; id?: string; type?: InputType; @@ -26,6 +26,7 @@ export interface InputProps { min?: number; max?: number; step?: number; + validation?: RegisterOptions; } // Styled Components @@ -37,13 +38,20 @@ const StyledInputContainer = styled.div` position: relative; `; -const StyledLabel = styled.label<{ hasError?: boolean; required?: boolean }>` +interface StyledLabelProps { + hasError?: boolean; + required?: boolean; +} + +const StyledLabel = styled.label` font-family: var(--font-family-primary); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); - color: ${props => props.hasError ? 'var(--color-error)' : 'var(--color-text)'}; - - ${props => props.required && ` + color: ${({ hasError }) => (hasError ? 'var(--color-error)' : 'var(--color-text)')}; + + ${({ required }) => + required && + ` &::after { content: '*'; color: var(--color-error); @@ -52,7 +60,11 @@ const StyledLabel = styled.label<{ hasError?: boolean; required?: boolean }>` `} `; -const StyledInput = styled.input<{ hasError?: boolean }>` +interface StyledInputProps { + hasError?: boolean; +} + +const StyledInput = styled.input` width: 100%; height: var(--input-height); padding: var(--input-padding); @@ -60,27 +72,27 @@ const StyledInput = styled.input<{ hasError?: boolean }>` font-size: var(--font-size-md); color: var(--color-text); background-color: var(--color-background); - border: 1px solid ${props => - props.hasError ? 'var(--color-error)' : 'var(--border-color-normal)'}; + border: 1px solid + ${({ hasError }) => (hasError ? 'var(--color-error)' : 'var(--border-color-normal)')}; border-radius: var(--border-radius-sm); transition: all var(--transition-fast); - + &:hover:not(:disabled) { border-color: var(--color-primary); } - + &:focus-visible { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); } - + &:disabled { background-color: var(--color-primary-light); cursor: not-allowed; opacity: 0.7; } - + &::placeholder { color: var(--color-text); opacity: 0.5; @@ -106,108 +118,101 @@ const ScreenReaderOnly = styled.span` border: 0; `; -export const Input = React.memo(({ - name, - id, - type = 'text', - value, - onChange, - label, - error, - placeholder, - required = false, - disabled = false, - className, - 'aria-label': ariaLabel, - autoComplete, - inputMode, - pattern, - min, - max, - step -}: InputProps) => { - const inputRef = useRef(null); - const errorRef = useRef(null); - const inputId = id || `input-${name}`; - const formContext = useFormContext(); - - // Handle form context integration if available - const fieldState = formContext?.getFieldState(name); - const fieldError = error || fieldState?.error?.message; - - // Announce errors to screen readers - useEffect(() => { - if (fieldError && errorRef.current) { - errorRef.current.focus(); - } - }, [fieldError]); - - // Handle form registration if form context exists - const registerProps = formContext ? formContext.register(name, { required }) : {}; - - const handleChange = (e: React.ChangeEvent) => { - if (onChange) { - onChange(e); - } - if (formContext) { - registerProps.onChange(e); - } - }; - - return ( - - - {label} - - - - - {fieldError && ( - <> - - {fieldError} - - - {fieldError} - - - )} - - ); -}); +export const Input = React.memo( + ({ + name, + id, + type = 'text', + value, + onChange, + label, + error, + placeholder, + required = false, + disabled = false, + className, + 'aria-label': ariaLabel, + autoComplete, + inputMode, + pattern, + min, + max, + step, + validation, + ...rest + }: InputProps) => { + const inputRef = useRef(null); + const errorRef = useRef(null); + const inputId = id || `input-${name}`; + const formContext = useFormContext(); + + // Handle form context integration if available + const fieldState = formContext?.getValues(name); + const fieldError = error || fieldState?.error?.message; + + // Announce errors to screen readers + useEffect(() => { + if (fieldError && errorRef.current) { + errorRef.current.focus(); + } + }, [fieldError]); + + // Handle form registration if form context exists + const registerProps = formContext + ? (formContext.register(name, { required, ...validation }) as ReturnType< + UseFormRegister + >) + : {}; + + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e); + formContext?.register(name, { required, ...validation }).onChange?.(e); + }; + + return ( + + + {label} + + + + + {fieldError && ( + <> + + {fieldError} + + + {fieldError} + + + )} + + ); + } +); Input.displayName = 'Input'; -export default Input; \ No newline at end of file +export default Input; diff --git a/src/web/src/components/common/Select.tsx b/src/web/src/components/common/Select.tsx index b9f0f395d3..35514e699c 100644 --- a/src/web/src/components/common/Select.tsx +++ b/src/web/src/components/common/Select.tsx @@ -28,104 +28,93 @@ interface SelectProps { * A reusable select component with comprehensive accessibility features, * loading states, and error handling. */ -const Select: React.FC = React.memo(({ - options, - value, - onChange, - name, - id, - label, - placeholder, - disabled = false, - error, - loading = false, - required = false, - className = '', -}) => { - // Generate unique IDs for accessibility - const selectId = useMemo(() => id || `select-${name}`, [id, name]); - const errorId = useMemo(() => `${selectId}-error`, [selectId]); - - // Handle change events with proper type conversion - const handleChange = useCallback((event: React.ChangeEvent) => { - const newValue = event.target.value; - // Convert to number if the current value is a number - onChange(typeof value === 'number' ? Number(newValue) : newValue); - }, [onChange, value]); - - // Compute select classes based on state - const selectClasses = useMemo(() => { - const classes = ['select']; - if (error) classes.push('select-error'); - if (disabled) classes.push('select-disabled'); - if (className) classes.push(className); - return classes.join(' '); - }, [error, disabled, className]); - - return ( -
- {label && ( - - )} - -
- - - {loading && ( - +const Select: React.FC = React.memo( + ({ + options, + value, + onChange, + name, + id, + label, + placeholder, + disabled = false, + error, + loading = false, + required = false, + className = '', + }) => { + // Generate unique IDs for accessibility + const selectId = useMemo(() => id || `select-${name}`, [id, name]); + const errorId = useMemo(() => `${selectId}-error`, [selectId]); + + // Handle change events with proper type conversion + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const newValue = event.target.value; + // Convert to number if the current value is a number + onChange(typeof value === 'number' ? Number(newValue) : newValue); + }, + [onChange, value] + ); + + // Compute select classes based on state + const selectClasses = useMemo(() => { + const classes = ['select']; + if (error) classes.push('select-error'); + if (disabled) classes.push('select-disabled'); + if (className) classes.push(className); + return classes.join(' '); + }, [error, disabled, className]); + + return ( +
+ {label && ( + )} -
- {error && ( - - ); -}); +
+ ); + } +); // Display name for debugging Select.displayName = 'Select'; -export default Select; \ No newline at end of file +export default Select; diff --git a/src/web/src/components/common/Table.tsx b/src/web/src/components/common/Table.tsx index dab1f3532d..02f63916ad 100644 --- a/src/web/src/components/common/Table.tsx +++ b/src/web/src/components/common/Table.tsx @@ -3,21 +3,24 @@ import classnames from 'classnames'; // v2.3.1 import LoadingSpinner from './LoadingSpinner'; import { theme } from '../../config/theme'; +// Generic type for table data +export type TableData = Record; + // Column configuration interface -export interface TableColumn { +export interface TableColumn { id: string; header: string; - accessor: string | ((row: any) => any); + accessor: keyof T | ((row: T) => unknown); sortable?: boolean; width?: string; - renderCell?: (value: any, row: any) => React.ReactNode; + renderCell?: (value: unknown, row: T) => React.ReactNode; align?: 'left' | 'center' | 'right'; } // Table props interface -export interface TableProps { - columns: TableColumn[]; - data: any[]; +export interface TableProps { + columns: TableColumn[]; + data: T[]; isLoading?: boolean; sortable?: boolean; className?: string; @@ -27,7 +30,7 @@ export interface TableProps { rowHeight?: number; } -const Table: React.FC = ({ +const Table = ({ columns, data, isLoading = false, @@ -37,76 +40,81 @@ const Table: React.FC = ({ onSort, virtualized = false, rowHeight = 48, -}) => { +}: TableProps): React.ReactElement => { const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const tableRef = useRef(null); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); // Handle column sorting - const handleSort = useCallback((columnId: string, event: React.MouseEvent | React.KeyboardEvent) => { - if (!sortable || (event.type === 'keydown' && (event as React.KeyboardEvent).key !== 'Enter')) { - return; - } - - const newDirection = columnId === sortColumn && sortDirection === 'asc' ? 'desc' : 'asc'; - setSortColumn(columnId); - setSortDirection(newDirection); - onSort?.(columnId, newDirection); - - // Manage focus for accessibility - const target = event.target as HTMLElement; - target.setAttribute('aria-sort', newDirection); - }, [sortable, sortColumn, sortDirection, onSort]); - - // Virtual scrolling calculation + const handleSort = useCallback( + (columnId: string, event: React.MouseEvent | React.KeyboardEvent) => { + if ( + !sortable || + (event.type === 'keydown' && (event as React.KeyboardEvent).key !== 'Enter') + ) { + return; + } + + const newDirection = sortColumn === columnId && sortDirection === 'asc' ? 'desc' : 'asc'; + setSortColumn(columnId); + setSortDirection(newDirection); + onSort?.(columnId, newDirection); + }, + [sortable, sortColumn, sortDirection, onSort] + ); + + // Handle scroll for virtualization useEffect(() => { if (!virtualized || !tableRef.current) return; - const observer = new IntersectionObserver( - (entries) => { - const tableHeight = tableRef.current?.clientHeight ?? 0; - const visibleRows = Math.ceil(tableHeight / rowHeight); - const buffer = Math.floor(visibleRows / 2); + const handleScroll = (): void => { + const table = tableRef.current; + if (!table) return; - const start = Math.max(0, visibleRange.start - buffer); - const end = Math.min(data.length, visibleRange.end + buffer); + const scrollTop = table.scrollTop; + const viewportHeight = table.clientHeight; + const totalHeight = table.scrollHeight; - setVisibleRange({ start, end }); - }, - { root: tableRef.current, threshold: 0.1 } - ); + const start = Math.floor(scrollTop / rowHeight); + const visibleRows = Math.ceil(viewportHeight / rowHeight); + const end = Math.min(start + visibleRows + 10, data.length); + + setVisibleRange({ start: Math.max(0, start - 10), end }); + }; + + const table = tableRef.current; + table.addEventListener('scroll', handleScroll); + handleScroll(); - observer.observe(tableRef.current); - return () => observer.disconnect(); - }, [virtualized, rowHeight, data.length, visibleRange]); + return () => { + table.removeEventListener('scroll', handleScroll); + }; + }, [virtualized, data.length, rowHeight]); // Render table header - const renderHeader = () => ( + const renderHeader = (): React.ReactNode => ( {columns.map((column) => ( handleSort(column.id, e) : undefined} onKeyDown={column.sortable ? (e) => handleSort(column.id, e) : undefined} - tabIndex={column.sortable ? 0 : -1} - role={column.sortable ? 'columnheader button' : 'columnheader'} - aria-sort={sortColumn === column.id ? sortDirection : undefined} + tabIndex={column.sortable ? 0 : undefined} + role={column.sortable ? 'button' : undefined} > -
+
{column.header} - {column.sortable && sortColumn === column.id && ( - + {sortable && column.sortable && sortColumn === column.id && ( + {sortDirection === 'asc' ? '↑' : '↓'} )}
@@ -116,93 +124,71 @@ const Table: React.FC = ({ ); // Render table rows - const renderRows = () => { - if (isLoading) { - return ( - - - - - - ); - } - - if (!data.length) { - return ( - - - {emptyMessage} - - - ); - } - + const renderRows = (): React.ReactNode => { const rowsToRender = virtualized ? data.slice(visibleRange.start, visibleRange.end) : data; - return rowsToRender.map((row, index) => ( - - {columns.map((column) => { - const value = typeof column.accessor === 'function' - ? column.accessor(row) - : row[column.accessor]; - - return ( - - {column.renderCell ? column.renderCell(value, row) : value} - - ); - })} - - )); + return ( + + {rowsToRender.map((row, index) => ( + + {columns.map((column) => { + const value = + typeof column.accessor === 'function' ? column.accessor(row) : row[column.accessor]; + + return ( + + {column.renderCell ? column.renderCell(value, row) : (value as React.ReactNode)} + + ); + })} + + ))} + + ); }; return ( -
- - {renderHeader()} - - {renderRows()} - -
+
+ {isLoading ? ( +
+ +
+ ) : ( + + {renderHeader()} + {data.length > 0 ? ( + renderRows() + ) : ( + + + + + + )} +
+ {emptyMessage} +
+ )}
); }; Table.displayName = 'Table'; -export default Table; \ No newline at end of file +export default Table; diff --git a/src/web/src/components/common/Toast.tsx b/src/web/src/components/common/Toast.tsx index 8a6104d2f6..b613eca065 100644 --- a/src/web/src/components/common/Toast.tsx +++ b/src/web/src/components/common/Toast.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useRef } from 'react'; import classNames from 'classnames'; // ^2.3.2 import { useSwipeable } from 'react-swipeable'; // ^7.0.0 import { ToastType, ToastPosition } from '../../hooks/useToast'; -import { animations } from '../../styles/animations.css'; +import '../../styles/animations.css'; import ErrorBoundary from './ErrorBoundary'; // Props interfaces @@ -28,119 +28,127 @@ interface ToastContainerProps { rtl?: boolean; } +interface ToastStyles { + container: React.CSSProperties; + toast: React.CSSProperties; + content: React.CSSProperties; + icon: React.CSSProperties; + message: React.CSSProperties; + closeButton: React.CSSProperties; + progressBar: React.CSSProperties; +} + // Toast component with accessibility and mobile support -const Toast: React.FC = React.memo(({ - id, - message, - type, - position, - onClose, - autoClose = 5000, - theme = 'light', - rtl = false, - className, - testId = 'toast' -}) => { - const timerRef = useRef(); - const messageRef = useRef(null); +const Toast: React.FC = React.memo( + ({ + id, + message, + type, + position, + onClose, + autoClose = 5000, + theme = 'light', + rtl = false, + className, + testId = 'toast', + }) => { + const timerRef = useRef>(); + const messageRef = useRef(null); - // Handle auto-close timer - useEffect(() => { - if (autoClose > 0) { - timerRef.current = setTimeout(() => { - onClose(id); - }, autoClose); - } + // Handle auto-close timer + useEffect(() => { + if (autoClose > 0) { + timerRef.current = setTimeout(() => { + onClose(id); + }, autoClose); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [id, autoClose, onClose]); - return () => { + // Handle mouse interactions + const handleMouseEnter = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); } - }; - }, [id, autoClose, onClose]); - - // Handle mouse interactions - const handleMouseEnter = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - }, []); + }, []); - const handleMouseLeave = useCallback(() => { - if (autoClose > 0) { - timerRef.current = setTimeout(() => { - onClose(id); - }, autoClose); - } - }, [id, autoClose, onClose]); + const handleMouseLeave = useCallback(() => { + if (autoClose > 0) { + timerRef.current = setTimeout(() => { + onClose(id); + }, autoClose); + } + }, [id, autoClose, onClose]); - // Handle swipe gestures for mobile - const swipeHandlers = useSwipeable({ - onSwipedLeft: () => !rtl && onClose(id), - onSwipedRight: () => rtl && onClose(id), - preventDefaultTouchmoveEvent: true, - trackMouse: true - }); + // Handle swipe gestures for mobile + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => !rtl && onClose(id), + onSwipedRight: () => rtl && onClose(id), + preventScrollOnSwipe: true, + trackMouse: true, + }); - // Compute toast classes - const toastClasses = classNames( - 'toast', - `toast--${type}`, - `toast--${position}`, - { - 'toast--rtl': rtl, - 'toast--light': theme === 'light', - 'toast--dark': theme === 'dark' - }, - animations['fade-in'], - animations['slide-in'], - className - ); + // Compute toast classes + const toastClasses = classNames( + 'toast', + `toast--${type}`, + `toast--${position}`, + { + 'toast--rtl': rtl, + 'toast--light': theme === 'light', + 'toast--dark': theme === 'dark', + }, + 'fade-in', + 'slide-in', + className + ); - return ( -
-
-
- {getToastIcon(type)} -
-
- {message} + return ( +
+
+
+ {getToastIcon(type)} +
+
+ {message} +
+
- + {autoClose > 0 && ( +
+ )}
- {autoClose > 0 && ( -
- )} -
- ); -}); + ); + } +); // Toast container component export const ToastContainer: React.FC = ({ @@ -149,15 +157,12 @@ export const ToastContainer: React.FC = ({ limit = 3, containerClassName, theme = 'light', - rtl = false + rtl = false, }) => { - const containerClasses = classNames( - 'toast-container', - containerClassName - ); + const containerClasses = classNames('toast-container', containerClassName); // Group toasts by position - const groupedToasts = toasts.reduce((acc, toast) => { + const groupedToasts = toasts.reduce>((acc, toast) => { if (!acc[toast.position]) { acc[toast.position] = []; } @@ -173,22 +178,14 @@ export const ToastContainer: React.FC = ({ className={containerClasses} style={{ ...styles.container, - ...getPositionStyles(position as ToastPosition) + ...getPositionStyles(position as ToastPosition), }} aria-label="Notifications" role="region" > - {positionToasts - .slice(-limit) - .map(toast => ( - - ))} + {positionToasts.slice(-limit).map((toast) => ( + + ))}
))} @@ -206,6 +203,8 @@ const getToastIcon = (type: ToastType): JSX.Element => { return ; case ToastType.INFO: return ; + default: + return ; } }; @@ -213,7 +212,7 @@ const getToastIcon = (type: ToastType): JSX.Element => { const getPositionStyles = (position: ToastPosition): React.CSSProperties => { const baseStyles: React.CSSProperties = { position: 'fixed', - zIndex: 'var(--z-index-toast)' + zIndex: 9999, }; switch (position) { @@ -225,62 +224,63 @@ const getPositionStyles = (position: ToastPosition): React.CSSProperties => { return { ...baseStyles, bottom: 20, right: 20 }; case ToastPosition.BOTTOM_LEFT: return { ...baseStyles, bottom: 20, left: 20 }; + default: + return { ...baseStyles, top: 20, right: 20 }; } }; // Styles -const styles = { +const styles: ToastStyles = { container: { display: 'flex', - flexDirection: 'column' as const, - gap: 'var(--spacing-sm)' + flexDirection: 'column', + gap: 8, }, toast: { backgroundColor: 'var(--color-background)', - borderRadius: 'var(--border-radius-md)', - boxShadow: 'var(--shadow-md)', - minWidth: '300px', - maxWidth: '500px', - overflow: 'hidden' + borderRadius: 8, + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + minWidth: 300, + maxWidth: 500, + overflow: 'hidden', }, content: { display: 'flex', alignItems: 'center', - padding: 'var(--spacing-md)', - gap: 'var(--spacing-sm)' + padding: 12, + gap: 8, }, icon: { display: 'flex', alignItems: 'center', justifyContent: 'center', - width: '24px', - height: '24px' + width: 24, + height: 24, + flexShrink: 0, }, message: { flex: 1, - fontSize: 'var(--font-size-sm)', - lineHeight: 'var(--line-height-normal)', - color: 'var(--color-text)' + marginRight: 8, + fontSize: 14, + lineHeight: 1.5, }, closeButton: { background: 'none', border: 'none', - padding: 'var(--spacing-xs)', + padding: '4px 8px', cursor: 'pointer', - fontSize: '20px', - color: 'var(--color-text)', - opacity: 0.7, - transition: 'opacity var(--transition-fast)' + fontSize: 18, + opacity: 0.6, + transition: 'opacity 0.2s', }, progressBar: { - height: '3px', - background: 'var(--color-accent)', + height: 4, + background: 'var(--color-primary)', animation: 'progress-bar linear forwards', - transformOrigin: 'left' - } + }, }; Toast.displayName = 'Toast'; ToastContainer.displayName = 'ToastContainer'; -export default Toast; \ No newline at end of file +export default Toast; diff --git a/src/web/src/components/common/Tooltip.tsx b/src/web/src/components/common/Tooltip.tsx index 193b32797b..daebfc7ccb 100644 --- a/src/web/src/components/common/Tooltip.tsx +++ b/src/web/src/components/common/Tooltip.tsx @@ -4,9 +4,11 @@ import '../../styles/theme.css'; import '../../styles/animations.css'; // Types and Interfaces +type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; + interface TooltipProps { content: React.ReactNode; - position?: 'top' | 'right' | 'bottom' | 'left'; + position?: TooltipPosition; children: React.ReactNode; className?: string; delay?: number; @@ -21,8 +23,19 @@ interface Position { left: string; } +interface TooltipContainerProps { + tooltipPosition: TooltipPosition; + isVisible: boolean; + style?: React.CSSProperties; +} + // Styled Components -const TooltipContainer = styled.div<{ position: string; isVisible: boolean }>` +const TooltipContainer = styled.div.attrs( + (props: TooltipContainerProps) => ({ + style: props.style, + 'data-testid': 'tooltip-container', + }) +)` position: fixed; background-color: var(--color-primary); color: #ffffff; @@ -32,7 +45,7 @@ const TooltipContainer = styled.div<{ position: string; isVisible: boolean }>` max-width: 300px; z-index: var(--z-index-tooltip); pointer-events: none; - opacity: ${({ isVisible }) => (isVisible ? 1 : 0)}; + opacity: ${({ isVisible }: TooltipContainerProps) => (isVisible ? 1 : 0)}; transition: opacity var(--transition-fast) ease-in-out; box-shadow: var(--shadow-md); @@ -50,97 +63,70 @@ const TooltipTrigger = styled.div` const getTooltipPosition = ( triggerRect: DOMRect, tooltipRect: DOMRect, - position: string, + position: TooltipPosition, offset: number ): Position => { - const spacing = offset || 8; - let top = 0; - let left = 0; + let top = '0'; + let left = '0'; switch (position) { case 'top': - top = triggerRect.top - tooltipRect.height - spacing; - left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; + top = `${triggerRect.top - tooltipRect.height - offset}px`; + left = `${triggerRect.left + (triggerRect.width - tooltipRect.width) / 2}px`; break; case 'right': - top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; - left = triggerRect.right + spacing; + top = `${triggerRect.top + (triggerRect.height - tooltipRect.height) / 2}px`; + left = `${triggerRect.right + offset}px`; break; case 'bottom': - top = triggerRect.bottom + spacing; - left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; + top = `${triggerRect.bottom + offset}px`; + left = `${triggerRect.left + (triggerRect.width - tooltipRect.width) / 2}px`; break; case 'left': - top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; - left = triggerRect.left - tooltipRect.width - spacing; + top = `${triggerRect.top + (triggerRect.height - tooltipRect.height) / 2}px`; + left = `${triggerRect.left - tooltipRect.width - offset}px`; break; } - // Viewport boundary checks - const viewport = { - width: window.innerWidth, - height: window.innerHeight, - }; - - if (left < 0) left = spacing; - if (left + tooltipRect.width > viewport.width) { - left = viewport.width - tooltipRect.width - spacing; - } - if (top < 0) top = spacing; - if (top + tooltipRect.height > viewport.height) { - top = viewport.height - tooltipRect.height - spacing; - } - - return { - top: `${Math.round(top)}px`, - left: `${Math.round(left)}px`, - }; + return { top, left }; }; -// Custom Hook for Position Management const useTooltipPosition = ( triggerRef: React.RefObject, tooltipRef: React.RefObject, - position: string, + position: TooltipPosition, offset: number -) => { - const [tooltipPosition, setTooltipPosition] = useState({ top: '0', left: '0' }); +): Position | null => { + const [tooltipPosition, setTooltipPosition] = useState(null); const updatePosition = useCallback(() => { - if (triggerRef.current && tooltipRef.current) { - const triggerRect = triggerRef.current.getBoundingClientRect(); - const tooltipRect = tooltipRef.current.getBoundingClientRect(); - const newPosition = getTooltipPosition(triggerRect, tooltipRect, position, offset); - setTooltipPosition(newPosition); - } - }, [triggerRef, tooltipRef, position, offset]); + if (!triggerRef.current || !tooltipRef.current) return null; - useEffect(() => { - updatePosition(); + const triggerRect = triggerRef.current.getBoundingClientRect(); + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const newPosition = getTooltipPosition(triggerRect, tooltipRect, position, offset); - const resizeObserver = new ResizeObserver(updatePosition); - const scrollHandler = () => { - requestAnimationFrame(updatePosition); - }; + setTooltipPosition(newPosition); + return newPosition; + }, [position, offset]); - if (triggerRef.current) { - resizeObserver.observe(triggerRef.current); - } + useEffect(() => { + const handleScroll = (): void => { + updatePosition(); + }; - window.addEventListener('scroll', scrollHandler, true); - window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleScroll); return () => { - resizeObserver.disconnect(); - window.removeEventListener('scroll', scrollHandler, true); - window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleScroll); }; }, [updatePosition]); return tooltipPosition; }; -// Main Component export const Tooltip: React.FC = ({ content, position = 'top', @@ -155,33 +141,35 @@ export const Tooltip: React.FC = ({ const [isVisible, setIsVisible] = useState(false); const triggerRef = useRef(null); const tooltipRef = useRef(null); - const timeoutRef = useRef(); + const timeoutRef = useRef(); + const tooltipPosition = useTooltipPosition(triggerRef, tooltipRef, position, offset); const showTooltip = useCallback(() => { if (disabled) return; - timeoutRef.current = setTimeout(() => { + clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { setIsVisible(true); onShow?.(); }, delay); - }, [disabled, delay, onShow]); + }, [delay, disabled, onShow]); const hideTooltip = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + clearTimeout(timeoutRef.current); setIsVisible(false); onHide?.(); }, [onHide]); useEffect(() => { return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + clearTimeout(timeoutRef.current); }; }, []); + if (disabled) { + return <>{children}; + } + return ( = ({ onBlur={hideTooltip} > {children} - {isVisible && ( + {isVisible && tooltipPosition && ( {content} @@ -208,4 +198,4 @@ export const Tooltip: React.FC = ({ ); }; -export default Tooltip; \ No newline at end of file +export default Tooltip; diff --git a/src/web/src/components/layout/Header.tsx b/src/web/src/components/layout/Header.tsx index c1923e7276..e6d79fa382 100644 --- a/src/web/src/components/layout/Header.tsx +++ b/src/web/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, memo } from 'react'; import { useNavigate } from 'react-router-dom'; // v6.0.0 import { useMetrics } from '../../hooks/useMetrics'; import { ProfileMenu } from '../user/ProfileMenu'; @@ -13,14 +13,14 @@ interface HeaderProps { onThemeChange: (theme: 'light' | 'dark') => void; } -export const Header: React.FC = React.memo( +const Header: React.FC = memo( ({ className = '', testId = 'main-header', onThemeChange }) => { const navigate = useNavigate(); const { loading } = useMetrics(); // Handle logo click navigation const handleLogoClick = useCallback( - (event: React.MouseEvent | React.KeyboardEvent) => { + (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault(); if (event.type === 'keydown' && (event as React.KeyboardEvent).key !== 'Enter') { return; diff --git a/src/web/src/components/layout/Layout.tsx b/src/web/src/components/layout/Layout.tsx index 13b6812dde..ec8f6dd148 100644 --- a/src/web/src/components/layout/Layout.tsx +++ b/src/web/src/components/layout/Layout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, memo } from 'react'; import styled from '@emotion/styled'; import Header from './Header'; import Footer from './Footer'; @@ -14,6 +14,8 @@ interface LayoutProps { direction?: 'ltr' | 'rtl'; } +type SessionStatus = 'valid' | 'invalid' | 'loading'; + // Styled Components const LayoutContainer = styled.div<{ direction: 'ltr' | 'rtl' }>` display: flex; @@ -58,127 +60,126 @@ const SkipLink = styled.a` `; // Layout Component -const Layout: React.FC = React.memo( - ({ children, className = '', direction = 'ltr' }) => { - // State Management - const [sidebarOpen, setSidebarOpen] = useState(true); - const { validateSession, sessionStatus } = useAuth(); - const [theme, setTheme] = useState<'light' | 'dark'>('light'); - const { showToast } = useToast(); - - // Session Monitoring - useEffect(() => { - const checkSession = async () => { - try { - const isValid = await validateSession(); - if (!isValid) { - showToast( - 'Your session has expired. Please log in again.', - ToastType.WARNING, - ToastPosition.TOP_RIGHT - ); - } - } catch (error) { - console.error('Session validation failed:', error); +const Layout: React.FC = memo(({ children, className = '', direction = 'ltr' }) => { + // State Management + const [sidebarOpen, setSidebarOpen] = useState(true); + const { validateSession, sessionStatus } = useAuth(); + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const { showToast } = useToast(); + + // Session Monitoring + useEffect(() => { + const checkSession = async () => { + try { + const isValid = await validateSession(); + if (!isValid) { + showToast( + 'Your session has expired. Please log in again.', + ToastType.WARNING, + ToastPosition.TOP_RIGHT + ); } - }; - - const sessionInterval = setInterval(checkSession, 60000); // Check every minute - return () => clearInterval(sessionInterval); - }, [validateSession]); - - // Theme Change Handler - const handleThemeChange = useCallback((newTheme: 'light' | 'dark') => { - setTheme(newTheme); - document.documentElement.classList.toggle('theme-dark', newTheme === 'dark'); - }, []); - - // Sidebar Toggle Handler - const toggleSidebar = useCallback(() => { - setSidebarOpen((prev) => !prev); - }, []); - - // Error Handler - const handleError = useCallback((error: Error) => { - showToast(error.message, ToastType.ERROR, ToastPosition.TOP_RIGHT); - }, []); - - // Keyboard Navigation Handler - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.altKey) { - switch (event.key) { - case 'n': - const skipLink = document.querySelector('#skip-to-content'); - if (skipLink instanceof HTMLElement) { - skipLink.focus(); - } - break; - case 'm': - toggleSidebar(); - break; - default: - break; + } catch (error) { + console.error('Session validation failed:', error); + } + }; + + const sessionInterval = setInterval(checkSession, 60000); // Check every minute + return () => clearInterval(sessionInterval); + }, [validateSession]); + + // Theme Change Handler + const handleThemeChange = useCallback((newTheme: 'light' | 'dark') => { + setTheme(newTheme); + document.documentElement.classList.toggle('theme-dark', newTheme === 'dark'); + }, []); + + // Sidebar Toggle Handler + const toggleSidebar = useCallback(() => { + setSidebarOpen((prev) => !prev); + }, []); + + // Error Handler + const handleError = useCallback((error: Error) => { + showToast(error.message, ToastType.ERROR, ToastPosition.TOP_RIGHT); + }, []); + + // Keyboard Navigation Handler + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.altKey) { + switch (event.key) { + case 'n': { + const skipLink = document.querySelector('#skip-to-content') as HTMLElement | null; + if (skipLink) { + skipLink.focus(); + } + break; } + case 'm': + toggleSidebar(); + break; + default: + break; } - }, - [toggleSidebar] - ); - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - return ( - - { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + return ( + + + {/* Skip Navigation Link */} + + Skip to main content + + + {/* Header Component */} +
+ + {/* Sidebar Component */} + + + {/* Main Content Area */} + - {/* Skip Navigation Link */} - - Skip to main content - - - {/* Header Component */} -
- - {/* Sidebar Component */} - - - {/* Main Content Area */} - - {children} - - - {/* Footer Component */} -