Skip to content
Open
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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20.19.0
1,510 changes: 1,191 additions & 319 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
Expand All @@ -39,6 +42,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"jsdom": "^27.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7",
Expand Down
220 changes: 220 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";

describe("App Accessibility Tests", () => {
describe("1. Image Accessibility", () => {
it("should have descriptive alt text for the calculator image", () => {
render(<App />);
const image = screen.getByRole("img");

// Image should have alt attribute
const altText = image.getAttribute("alt");
expect(altText).toBeTruthy();
expect(altText!.length).toBeGreaterThan(0);
});
});

describe("2. Color Contrast", () => {
it("should use sufficient color contrast for text (WCAG AA compliant)", () => {
render(<App />);
const container = screen.getByText(/string calculator/i).parentElement;

// Check that text color is not low contrast #aaa
const styles = window.getComputedStyle(container!);
expect(styles.color).not.toBe("rgb(170, 170, 170)"); // #aaa in RGB
});

it("should have sufficient contrast for textarea text", () => {
render(<App />);
const textarea = screen.getByPlaceholderText(/enter numbers/i);
const styles = window.getComputedStyle(textarea);

// Textarea text should not be #aaa (low contrast)
expect(styles.color).not.toBe("rgb(170, 170, 170)");
});
});

describe("3. Heading Hierarchy", () => {
it("should have correct heading hierarchy starting with h1", () => {
render(<App />);
const headings = screen.getAllByRole("heading");

// First heading should be h1
expect(headings[0].tagName).toBe("H1");
expect(headings[0].textContent).toMatch(/string calculator/i);
});

it("should not skip heading levels", () => {
render(<App />);
const headings = screen.getAllByRole("heading");

// Should start with h1, then h2, etc. (no skipping)
const h1Count = headings.filter((h) => h.tagName === "H1").length;
expect(h1Count).toBeGreaterThan(0);
});
});

describe("4. Semantic HTML - Button", () => {
it("should use a proper button element instead of div", () => {
render(<App />);

// Should be able to find a button with "Calculate" text
const button = screen.getByRole("button", { name: /calculate/i });
expect(button).toBeTruthy();
expect(button.tagName).toBe("BUTTON");
});

it("should have button type attribute", () => {
render(<App />);
const button = screen.getByRole("button", { name: /calculate/i });

// Button should have explicit type
const typeAttr = button.getAttribute("type");
expect(typeAttr).toBeTruthy();
});
});

describe("5. Form Label Association", () => {
it("should have a label associated with the textarea", () => {
render(<App />);

// Should be able to find textarea by its label
const textarea = screen.getByLabelText(/enter numbers/i);
expect(textarea).toBeTruthy();
expect(textarea.tagName).toBe("TEXTAREA");
});

it("should use proper label element (not just aria-label)", () => {
render(<App />);
const textarea = screen.getByRole("textbox");
const id = textarea.getAttribute("id");

// Should have an id for label association
expect(id).toBeTruthy();

// Should have a corresponding label element
const label = document.querySelector(`label[for="${id}"]`);
expect(label).toBeTruthy();
});
});

describe("6. ARIA Usage", () => {
it('should not use role="alert" for static content', () => {
render(<App />);

// Static instructional content should not have role="alert"
const staticText = screen.getByText(
/make sure you enter numbers correctly/i
);
const alertElement = staticText.closest('[role="alert"]');

// If there's static content with role="alert", it's incorrect
// Role="alert" should only be used for dynamic announcements
expect(alertElement).toBeFalsy();
});

it('should use role="status" or aria-live for dynamic result announcements', () => {
render(<App />);

// When result is shown, it should have proper ARIA live region
// This test will check the implementation once results are working
const container = screen.getByText(/string calculator/i).parentElement;
expect(container).toBeTruthy();
});
});

describe("7. Keyboard Navigation", () => {
it("should allow keyboard navigation to the calculate button", async () => {
const user = userEvent.setup();
render(<App />);

const button = screen.getByRole("button", { name: /calculate/i });

// Should be able to tab to the button
await user.tab();
await user.tab();

// Button should receive focus
expect(document.activeElement).toBe(button);
});

it("should activate button with Enter key", async () => {
const user = userEvent.setup();
render(<App />);

const button = screen.getByRole("button", { name: /calculate/i });
button.focus();

// Button should be activatable with Enter
await user.keyboard("{Enter}");
// Test passes if no error occurs
expect(true).toBe(true);
});

it("should activate button with Space key", async () => {
const user = userEvent.setup();
render(<App />);

const button = screen.getByRole("button", { name: /calculate/i });
button.focus();

// Button should be activatable with Space
await user.keyboard(" ");
// Test passes if no error occurs
expect(true).toBe(true);
});
});

describe("8. Semantic HTML - Main Landmark", () => {
it("should use main element for primary content", () => {
render(<App />);

// Should have a main landmark
const main = screen.getByRole("main");
expect(main).toBeTruthy();
});
});

describe("9. Form Structure", () => {
it("should wrap input and button in a form element", () => {
render(<App />);

const textarea = screen.getByRole("textbox");
const form = textarea.closest("form");

// Textarea should be inside a form
expect(form).toBeTruthy();
});

it("should prevent default form submission", async () => {
const user = userEvent.setup();
render(<App />);

const textarea = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /calculate/i });

await user.type(textarea, "1,2,3");
await user.click(button);

// If we reach here without page reload, form prevents default
expect(true).toBe(true);
});
});

describe("10. Focus Indicators", () => {
it("should have visible focus indicators for interactive elements", () => {
render(<App />);

const button = screen.getByRole("button", { name: /calculate/i });
button.focus();

// Element should have focus
expect(document.activeElement).toBe(button);

// Note: Visual focus indicator would be tested with visual regression
// or by checking computed styles for outline
});
});
});
95 changes: 69 additions & 26 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,90 @@
import { useState } from 'react';
import { add } from './stringCalculator';

const App = () => {
const [input, setInput] = useState('');
const [result] = useState(null);
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string>('');

const handleCalculate = () => {};
const handleCalculate = () => {
try {
setError('');
const sum = add(input);
setResult(sum);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setResult(null);
}
};

return (
<div style={{ padding: '20px', backgroundColor: '#fff', color: '#aaa' }}>
<main style={{ padding: '20px', backgroundColor: '#fff', color: '#333' }}>
<img
src='https://images.unsplash.com/photo-1594352161389-11756265d1b5?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'
alt='An image of a string to depict the concept of a string calculator'
width={600}
height={400}
/>

<h2>String Calculator</h2>
<h1>String Calculator</h1>

<h1 style={{ fontSize: '20px' }}>Enter numbers</h1>

<textarea
style={{ margin: '10px 0', color: '#aaa' }}
placeholder='Enter numbers'
value={input}
onChange={(e) => setInput(e.target.value)}
/>

<div
onClick={handleCalculate}
style={{
padding: '10px',
backgroundColor: '#008cba',
color: '#fff',
border: 'none',
<form
onSubmit={(e) => {
e.preventDefault();
handleCalculate();
}}>
Calculate
</div>
<label
htmlFor='numbers-input'
style={{
fontSize: '1.2rem',
fontWeight: 'bold',
display: 'block',
marginBottom: '8px',
}}>
Enter numbers
</label>

<textarea
id='numbers-input'
style={{ margin: '10px 0', color: '#333', width: '100%', minHeight: '80px' }}
placeholder='Enter numbers for calculation(comma separated)...'
value={input}
onChange={(e) => setInput(e.target.value)}
/>

{result !== null && <p style={{ color: 'green' }}>Result: {result}</p>}
<button
type='submit'
style={{
padding: '10px 20px',
backgroundColor: '#008cba',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
}}>
Calculate
</button>
</form>

<div role='alert'>
<p>Make sure you enter numbers correctly!</p>
{/* Result display with ARIA live region for dynamic announcements */}
<div role='status' aria-live='polite' aria-atomic='true'>
{result !== null && !error && (
<p style={{ color: 'green', fontWeight: 'bold', marginTop: '15px' }}>
Result: {result}
</p>
)}
{error && (
<p style={{ color: 'red', fontWeight: 'bold', marginTop: '15px' }} role='alert'>
Error: {error}
</p>
)}
</div>
</div>

<p style={{ fontSize: '0.9rem', color: '#666', marginTop: '10px' }}>
Make sure you enter numbers correctly!
</p>
</main>
);
};

Expand Down
Loading