Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/src/components/HealthBadge.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { getHealthStatusClass } from '../utils/healthStatusClasses';

const HealthBadge = ({ status = 'neutral', label, children, className = '' }) => {
const badgeStyle = getHealthStatusClass(status);

return (
<span
role="status"
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${badgeStyle} ${className}`}
>
{label || children}
</span>
);
};

export default HealthBadge;
61 changes: 61 additions & 0 deletions frontend/src/components/HealthMetricCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useId } from 'react';
import HealthBadge from './HealthBadge';

const HealthMetricCard = ({
title,
value,
unit,
status = 'neutral',
statusLabel,
description,
onClick,
className = '',
}) => {
const isClickable = typeof onClick === 'function';
const idPrefix = useId();
const titleId = `${idPrefix}-title`;
const descId = description ? `${idPrefix}-desc` : undefined;

return (
<div
role={isClickable ? 'button' : 'region'}
tabIndex={isClickable ? 0 : undefined}
onClick={onClick}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
aria-labelledby={titleId}
aria-describedby={descId}
className={`p-5 bg-white rounded-xl border border-slate-200 shadow-sm transition-all ${
isClickable
? 'hover:shadow-md hover:border-teal-300 cursor-pointer focus:outline-none focus:ring-2 focus:ring-teal-500'
: ''
} ${className}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<h3 id={titleId} className="text-sm font-medium text-slate-600 truncate">
{title}
</h3>
{statusLabel && <HealthBadge status={status} label={statusLabel} />}
</div>
<div className="flex items-baseline gap-1 my-1">
<span className="text-2xl font-bold text-slate-900 tracking-tight">{value}</span>
{unit && <span className="text-xs text-slate-500 font-medium">{unit}</span>}
</div>
{description && (
<p id={descId} className="text-xs text-slate-500 mt-2">
{description}
</p>
)}
</div>
);
};

export default HealthMetricCard;
71 changes: 71 additions & 0 deletions frontend/src/components/HealthStateView.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useId } from 'react';

const HealthStateView = ({
type = 'empty', // 'loading' | 'empty' | 'error'
title,
message,
loadingLabel = 'Loading content',
actionLabel,
onAction,
className = '',
}) => {
const isError = type === 'error';
const isLoading = type === 'loading';
const titleId = useId();

return (
<div
role={isError ? 'alert' : 'region'}
aria-live={isError ? 'assertive' : 'polite'}
aria-labelledby={title ? titleId : undefined}
className={`flex flex-col items-center justify-center p-8 text-center rounded-xl border border-dashed ${
isError ? 'border-rose-200 bg-rose-50/40' : 'border-slate-200 bg-slate-50/50'
} ${className}`}
>
{isLoading ? (
<div
role="progressbar"
aria-label={loadingLabel}
className="w-10 h-10 border-4 border-teal-200 border-t-teal-600 rounded-full animate-spin mb-4"
/>
) : (
<div className="text-3xl mb-3" aria-hidden="true">
{isError ? '⚠️' : '📋'}
</div>
)}

{title && (
<h4
id={titleId}
className={`text-base font-semibold mb-1 ${
isError ? 'text-rose-900' : 'text-slate-800'
}`}
>
{title}
</h4>
)}

{message && (
<p
className={`text-sm max-w-sm mb-4 ${
isError ? 'text-rose-700' : 'text-slate-600'
}`}
>
{message}
</p>
)}

{actionLabel && onAction && (
<button
type="button"
onClick={onAction}
className="px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500"
>
{actionLabel}
</button>
)}
</div>
);
};

export default HealthStateView;
121 changes: 121 additions & 0 deletions frontend/src/components/HealthUIComponents.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import HealthBadge from './HealthBadge';
import HealthMetricCard from './HealthMetricCard';
import HealthStateView from './HealthStateView';

describe('Health UI Components (Issue #158 - PR Feedback)', () => {
describe('HealthBadge', () => {
it('renders with appropriate status label and role', () => {
render(<HealthBadge status="good" label="Optimal" />);
const badge = screen.getByRole('status');
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent('Optimal');
expect(badge.className).toContain('emerald');
});

it('renders critical status correctly', () => {
render(<HealthBadge status="critical" label="High Risk" />);
const badge = screen.getByRole('status');
expect(badge.className).toContain('rose');

Check failure on line 21 in frontend/src/components/HealthUIComponents.test.jsx

View workflow job for this annotation

GitHub Actions / Test (Node 20)

src/components/HealthUIComponents.test.jsx > Health UI Components (Issue #158 - PR Feedback) > HealthBadge > renders critical status correctly

AssertionError: expected 'inline-flex items-center px-2.5 py-0.…' to contain 'rose' Expected: "rose" Received: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border border-health-critical bg-health-critical/10 text-red-950 " ❯ src/components/HealthUIComponents.test.jsx:21:31

Check failure on line 21 in frontend/src/components/HealthUIComponents.test.jsx

View workflow job for this annotation

GitHub Actions / Test (Node 22)

src/components/HealthUIComponents.test.jsx > Health UI Components (Issue #158 - PR Feedback) > HealthBadge > renders critical status correctly

AssertionError: expected 'inline-flex items-center px-2.5 py-0.…' to contain 'rose' Expected: "rose" Received: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border border-health-critical bg-health-critical/10 text-red-950 " ❯ src/components/HealthUIComponents.test.jsx:21:31
});

it('handles unknown status with fallback to neutral classes', () => {
render(<HealthBadge status="unrecognized_status" label="Unknown State" />);
const badge = screen.getByRole('status');
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent('Unknown State');
expect(badge.className).toContain('slate');
});
});

describe('HealthMetricCard', () => {
it('renders title, value, unit, status, and description properly exposed to accessibility tree', () => {
render(
<HealthMetricCard
title="Blood Pressure"
value="120/80"
unit="mmHg"
status="good"
statusLabel="Optimal"
description="Normal resting rate"
/>
);

const card = screen.getByRole('region', { name: /blood pressure/i });
expect(card).toBeInTheDocument();
expect(screen.getByText('120/80')).toBeInTheDocument();
expect(screen.getByText('mmHg')).toBeInTheDocument();
expect(screen.getByText('Optimal')).toBeInTheDocument();
expect(screen.getByText('Normal resting rate')).toBeInTheDocument();
});

it('triggers action on Space key activation in addition to Enter', () => {
const handleClick = vi.fn();
render(
<HealthMetricCard
title="Interactive Metric"
value="72"
onClick={handleClick}
/>
);

const buttonCard = screen.getByRole('button', { name: /interactive metric/i });

fireEvent.keyDown(buttonCard, { key: 'Enter', code: 'Enter' });
expect(handleClick).toHaveBeenCalledTimes(1);

fireEvent.keyDown(buttonCard, { key: ' ', code: 'Space' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});

describe('HealthStateView', () => {
it('renders configurable loading indicator label in English and Arabic', () => {
const { rerender } = render(
<HealthStateView type="loading" loadingLabel="Loading content" />
);
expect(screen.getByRole('progressbar', { name: 'Loading content' })).toBeInTheDocument();

rerender(<HealthStateView type="loading" loadingLabel="جارٍ تحميل المحتوى" />);
expect(screen.getByRole('progressbar', { name: 'جارٍ تحميل المحتوى' })).toBeInTheDocument();
});

it('renders error state with assertive alert role and handles retry callback', () => {
const handleRetry = vi.fn();
render(
<HealthStateView
type="error"
title="Connection Error"
message="Unable to reach server"
actionLabel="Retry"
onAction={handleRetry}
/>
);

const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'assertive');
expect(screen.getByText('Connection Error')).toBeInTheDocument();
expect(screen.getByText('Unable to reach server')).toBeInTheDocument();

const retryBtn = screen.getByRole('button', { name: /retry/i });
fireEvent.click(retryBtn);
expect(handleRetry).toHaveBeenCalledTimes(1);
});

it('renders empty state in a region with polite announcement', () => {
render(
<HealthStateView
type="empty"
title="No records found"
message="Start by adding your first reading."
/>
);

const region = screen.getByRole('region', { name: /no records found/i });
expect(region).toHaveAttribute('aria-live', 'polite');
expect(screen.getByText('Start by adding your first reading.')).toBeInTheDocument();
});
});
});
3 changes: 3 additions & 0 deletions frontend/src/utils/healthStatusClasses.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ const HEALTH_STATUS_CLASSES = {
export function getHealthStatusClasses(status) {
return HEALTH_STATUS_CLASSES[status] || HEALTH_STATUS_CLASSES.neutral;
}

export const getHealthStatusClass = getHealthStatusClasses;
export const HEALTH_STATUS_STYLES = HEALTH_STATUS_CLASSES;
Loading