Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
39 changes: 38 additions & 1 deletion docs/components/StatusBadge.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This component eliminates duplicated status styling logic that previously existe

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `status` | `StatusType` | Yes | — | The status value to display. One of: `Active`, `Completed`, `Disputed`, `Pending`, or `Paid`. |
| `status` | `StatusType` | Yes | — | The status value to display. One of: `Active`, `Completed`, `Disputed`, `Pending`, or `Paid`. Defensive fallback if a non-typed value slips through at runtime (see [Unknown status fallback](#unknown-status-fallback)). |
| `className` | `string` | No | `''` | Additional CSS classes to apply to the badge element. |

## Status Types
Expand All @@ -32,6 +32,41 @@ Each status renders an icon + label token so meaning is never conveyed by color

Icons are rendered in a child `<span aria-hidden="true">` so screen readers only announce the label text.

## Unknown status fallback

The component defends against runtime values outside the `StatusType` union
(e.g. unvalidated API data, third-party integrations). When `status` is not
one of the five canonical values, `StatusBadge`:

1. Renders the **neutral** token `--status-neutral-*` for background/foreground
(defined in `globals.css`, light + dark pairs both pass WCAG AA contrast).
2. Renders the fallback icon `?` in the `aria-hidden` slot.
3. Sets `aria-label` to `Status: Unknown — value "<raw value>"` so AT users
hear that the value is unrecognised while still learning the raw value.
4. Shows the visible label as `Unknown (<raw value>)` so sighted users see
both the unknown indicator and the original value.
5. Logs a single `console.warn` in development (silent in production).

The `isKnownStatus(value)` type-guard is exported alongside the component
for callers that want to validate status data before rendering:

```tsx
import { isKnownStatus } from '@/components/StatusBadge';

const raw = await fetchStatusFromApi();
const normalized: StatusType = isKnownStatus(raw) ? raw : 'Unknown';

// TS prevents passing an unknown status string at the prop boundary,
// but this also handles data that crosses the type boundary at runtime.
```

Direct usage (e.g. an `as`-escape in tests) renders the fallback rather than crashing:

```tsx
<StatusBadge status={'Cancelled' as unknown as StatusType} />
// → [?] Unknown (Cancelled) with aria-label `Status: Unknown — value "Cancelled"`
```

## Usage Examples

### Basic Usage
Expand Down Expand Up @@ -109,6 +144,8 @@ The component includes comprehensive test coverage:
- **Props tests**: Tests additional className functionality
- **Accessibility tests**: Ensures proper ARIA attributes and roles
- **Snapshot tests**: Confirms visual consistency across status types
- **Unknown status fallback**: Neutral styling, fallback icon, accessible label, dev-only warning
- **isKnownStatus type-guard**: Returns true for canonical statuses, false for anything else

Run tests with: `npm test src/components/__tests__/StatusBadge.test.tsx`

Expand Down
12 changes: 12 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
--status-error-foreground: #9f1239;
--status-warning-bg: #fef3c7;
--status-warning-foreground: #92400e;

/* Neutral fallback for any status value outside the StatusType union.
Light-mode choice: slate-100 + slate-700 — sits between the four
coloured tokens so the unknown badge is unmistakably desaturated. */
--status-neutral-bg: #f1f5f9;
--status-neutral-foreground: #334155;
}

[data-theme='dark'] {
Expand Down Expand Up @@ -78,6 +84,12 @@
--status-error-foreground: #fda4af;
--status-warning-bg: #78350f;
--status-warning-foreground: #fcd34d;

/* Dark-mode neutral fallback: slate-800 background + slate-200 text.
Pairs sit at ~10.5:1 contrast against each other and stay clearly
desaturated versus the four coloured tokens. */
--status-neutral-bg: #1e293b;
--status-neutral-foreground: #e2e8f0;
}

body {
Expand Down
68 changes: 63 additions & 5 deletions src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@

export type StatusType = 'Active' | 'Completed' | 'Disputed' | 'Pending' | 'Paid';

/**
* Canonical set of acceptable statuses. Hoisted as a constant so the
* bundler can inline membership checks and DCE the dev-only warning path.
*/
const KNOWN_STATUSES: ReadonlySet<StatusType> = new Set<StatusType>([
'Active',
'Completed',
'Disputed',
'Pending',
'Paid',
]);

export interface StatusBadgeProps {
/** The status value to display */
status: StatusType;
Expand Down Expand Up @@ -41,28 +53,74 @@ export const statusIconMap: Record<StatusType, string> = {
Paid: '✔',
};

/**
* Fallback styling and icon for any status value that is outside the
* `StatusType` union. The TypeScript type prevents this at compile time,
* but runtime data (e.g. unvalidated API values) can still slip through;
* we render gracefully rather than crashing.
*
* Uses the `--status-neutral-*` CSS variables so the fallback respects
* the active theme. See docs/components/Accessibility.md for ratios.
*/
const FALLBACK_COLOR_CLASS =
'bg-[var(--status-neutral-bg)] text-[var(--status-neutral-foreground)]';
const FALLBACK_ICON = '?';

/** Compile-time-friendly prod flag so the warning path can be DCE'd in production. */
const IS_PRODUCTION = process.env.NODE_ENV === 'production';

/**
* Runtime type-guard: returns `true` when the supplied value is one of
* the five canonical contract/milestone statuses.
*/
export function isKnownStatus(value: unknown): value is StatusType {
return typeof value === 'string' && KNOWN_STATUSES.has(value as StatusType);
}

/**
* StatusBadge renders a pill with an icon + label for each status.
* The icon is decorative (`aria-hidden`); meaning is also carried by the
* visible label and `aria-label`, so it is never color-only.
*
* Unknown status values fall back to a neutral style, a question-mark
* icon, and an aria-label that explicitly says "Unknown" along with the
* raw value — preserving data while signalling the value is unrecognised.
*
* @example
* ```tsx
* <StatusBadge status="Completed" />
* <StatusBadge status="Pending" className="ml-2" />
* ```
*/
const StatusBadge = ({ status, className = '' }: StatusBadgeProps) => {
const known = isKnownStatus(status);
const rawText = String(status);

if (!known && !IS_PRODUCTION) {
// Surface unexpected values in development so callers can fix the
// upstream data; production runs are intentionally silent.
console.warn(
`[StatusBadge] Unknown status value: "${rawText}". Falling back to neutral styling.`,
);
}

const colorClass = known ? statusColorMap[status] : FALLBACK_COLOR_CLASS;
const icon = known ? statusIconMap[status] : FALLBACK_ICON;
const ariaLabel = known
? `Status: ${status}`
: `Status: Unknown — value "${rawText}"`;
const visibleLabel = known ? status : `Unknown (${rawText})`;

return (
<span
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold ${statusColorMap[status]} ${className}`}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold ${colorClass} ${className}`}
role="status"
aria-label={`Status: ${status}`}
aria-label={ariaLabel}
>
<span aria-hidden="true">{statusIconMap[status]}</span>
{status}
<span aria-hidden="true">{icon}</span>
{visibleLabel}
</span>
);
};

export default StatusBadge;
export default StatusBadge;
146 changes: 145 additions & 1 deletion src/components/__tests__/StatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import StatusBadge, { StatusType } from '../StatusBadge';
import StatusBadge, { StatusType, isKnownStatus } from '../StatusBadge';

const STATUS_ICONS: Record<StatusType, string> = {
Active: '▶',
Expand Down Expand Up @@ -158,6 +158,150 @@ describe('StatusBadge', () => {
});
});

describe('unknown status fallback', () => {
// StatusBadge is strictly typed for StatusType at the prop boundary,
// but the runtime needs to handle values that slip through (e.g. data
// from an API that hasn't been validated). The unknown-status path
// must be safe, accessible, and visually distinct from the canonical
// five statuses.

let warnSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

it('does not throw when status is not in StatusType', () => {
expect(() =>
render(
<StatusBadge status={'Cancelled' as unknown as StatusType} />,
),
).not.toThrow();
});

it('renders neutral styling classes for an unknown status', () => {
const { container } = render(
<StatusBadge status={'Cancelled' as unknown as StatusType} />,
);
const span = container.querySelector('span');
expect(span?.className).toContain('bg-[var(--status-neutral-bg)]');
expect(span?.className).toContain('text-[var(--status-neutral-foreground)]');
});

it('renders no known-status colour tokens for an unknown status', () => {
const { container } = render(
<StatusBadge status={'OnHold' as unknown as StatusType} />,
);
const span = container.querySelector('span');
expect(span?.className).not.toMatch(/var\(--status-(success|info|error|warning)-/);
});

it('renders the fallback question mark icon for an unknown status', () => {
const { container } = render(
<StatusBadge status={'Archived' as unknown as StatusType} />,
);
const iconSpan = container.querySelector('span[aria-hidden="true"]');
expect(iconSpan?.textContent).toBe('?');
});

it('uses a fallback aria-label that names the unknown status', () => {
render(
<StatusBadge status={'Archived' as unknown as StatusType} />,
);
const badge = screen.getByRole('status', {
name: 'Status: Unknown — value "Archived"',
});
expect(badge).toBeInTheDocument();
});

it('preserves the raw status string in the visible label', () => {
const { container } = render(
<StatusBadge status={'Archived' as unknown as StatusType} />,
);
expect(container).toHaveTextContent('Archived');
expect(container).toHaveTextContent('Unknown');
});

it('still applies base badge styles with unknown status', () => {
const { container } = render(
<StatusBadge status={'OnHold' as unknown as StatusType} />,
);
const span = container.querySelector('span');
expect(span?.className).toContain('inline-flex');
expect(span?.className).toContain('rounded-full');
expect(span?.className).toContain('px-3');
expect(span?.className).toContain('py-1');
expect(span?.className).toContain('font-semibold');
});

it('keeps the fallback neutral styling when additional className is supplied', () => {
const { container } = render(
<StatusBadge
status={'OnHold' as unknown as StatusType}
className="ml-2"
/>,
);
const span = container.querySelector('span');
expect(span?.className).toContain('bg-[var(--status-neutral-bg)]');
expect(span?.className).toContain('ml-2');
});

it('warns once in development when status is unknown', () => {
render(<StatusBadge status={'Cancelled' as unknown as StatusType} />);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[StatusBadge] Unknown status value: "Cancelled".'),
);
});

it('handles empty-string status without crashing', () => {
// Single render covers both the "does not throw" assertion (render
// throws synchronously when the component throws) and the visible
// output checks. Visible label is `Unknown ()` for empty-string
// status so the unknown indicator is still rendered.
const { container } = render(
<StatusBadge status={'' as unknown as StatusType} />,
);
const span = container.querySelector('span');
expect(span?.className).toContain('bg-[var(--status-neutral-bg)]');
expect(container).toHaveTextContent('Unknown ()');
});

it('handles numeric status without crashing', () => {
// Single render: if the component threw, render would itself throw.
const { container } = render(
<StatusBadge status={42 as unknown as StatusType} />,
);
expect(container).toHaveTextContent('Unknown (42)');
});
});

describe('isKnownStatus type-guard', () => {
it('returns true for each canonical status', () => {
(['Active', 'Completed', 'Disputed', 'Pending', 'Paid'] as StatusType[])
.forEach((status) => {
expect(isKnownStatus(status)).toBe(true);
});
});

it('returns false for unknown strings', () => {
expect(isKnownStatus('Cancelled')).toBe(false);
expect(isKnownStatus('OnHold')).toBe(false);
expect(isKnownStatus('')).toBe(false);
});

it('returns false for non-string values', () => {
expect(isKnownStatus(undefined)).toBe(false);
expect(isKnownStatus(null)).toBe(false);
expect(isKnownStatus(42)).toBe(false);
expect(isKnownStatus({})).toBe(false);
});
});

describe('snapshot tests', () => {
it('matches snapshot for Active status', () => {
const { container } = render(<StatusBadge status="Active" />);
Expand Down
Loading