Skip to content

Commit 955beef

Browse files
feat: add diff method toggle buttons
1 parent 642b39b commit 955beef

12 files changed

Lines changed: 456 additions & 28 deletions

File tree

specs/002-toggle-diff-options/tasks.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919

2020
**Purpose**: Add the `DiffMethod` type and the generic `useLocalStorage` hook that all subsequent tasks depend on.
2121

22-
- [ ] T001 Add `DiffMethod` type (`'characters' | 'words' | 'lines'`) to src/types/diff.ts
23-
- [ ] T002 [P] Write unit tests for `useLocalStorage` hook in src/hooks/useLocalStorage.test.ts (read/write, fallback on missing key, fallback on invalid JSON, update persists to localStorage)
24-
- [ ] T003 [P] Implement `useLocalStorage<T>` hook in src/hooks/useLocalStorage.ts (generic useState + localStorage read/write with JSON serialization and error fallback)
22+
- [x] T001 Add `DiffMethod` type (`'characters' | 'words' | 'lines'`) to src/types/diff.ts
23+
- [x] T002 [P] Write unit tests for `useLocalStorage` hook in src/hooks/useLocalStorage.test.ts (read/write, fallback on missing key, fallback on invalid JSON, update persists to localStorage)
24+
- [x] T003 [P] Implement `useLocalStorage<T>` hook in src/hooks/useLocalStorage.ts (generic useState + localStorage read/write with JSON serialization and error fallback)
2525

2626
**Checkpoint**: `DiffMethod` type exists, `useLocalStorage` hook is tested and working.
2727

@@ -33,8 +33,8 @@
3333

3434
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
3535

36-
- [ ] T004 Update `useDiff` hook to accept a third parameter `method: DiffMethod` and dispatch to `diffChars`/`diffWords`/`diffLines` in src/hooks/useDiff.ts
37-
- [ ] T005 Update `useDiff` tests to cover character-level and line-level diff methods in src/hooks/useDiff.test.ts
36+
- [x] T004 Update `useDiff` hook to accept a third parameter `method: DiffMethod` and dispatch to `diffChars`/`diffWords`/`diffLines` in src/hooks/useDiff.ts
37+
- [x] T005 Update `useDiff` tests to cover character-level and line-level diff methods in src/hooks/useDiff.test.ts
3838

3939
**Checkpoint**: `useDiff` supports all three diff methods with tests passing.
4040

@@ -50,15 +50,15 @@
5050

5151
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
5252
53-
- [ ] T006 [P] [US1] Write unit tests for `DiffMethodToggle` component in src/components/DiffMethodToggle/DiffMethodToggle.test.tsx (renders three buttons for characters/words/lines, highlights active method, calls onMethodChange on click, is keyboard accessible)
54-
- [ ] T007 [P] [US1] Write integration tests for diff method switching in src/components/App/App.test.tsx (switching method changes diff output, default is "words", localStorage persistence restores selection on re-render)
53+
- [x] T006 [P] [US1] Write unit tests for `DiffMethodToggle` component in src/components/DiffMethodToggle/DiffMethodToggle.test.tsx (renders three buttons for characters/words/lines, highlights active method, calls onMethodChange on click, is keyboard accessible)
54+
- [x] T007 [P] [US1] Write integration tests for diff method switching in src/components/App/App.test.tsx (switching method changes diff output, default is "words", localStorage persistence restores selection on re-render)
5555

5656
### Implementation for User Story 1
5757

58-
- [ ] T008 [P] [US1] Create `DiffMethodToggleProps` interface in src/components/DiffMethodToggle/DiffMethodToggle.types.ts
59-
- [ ] T009 [P] [US1] Create barrel export in src/components/DiffMethodToggle/index.ts
60-
- [ ] T010 [US1] Implement `DiffMethodToggle` component with 3-button segmented group (Characters | Words | Lines), `role="group"`, `aria-label`, active/inactive Tailwind styling per research.md in src/components/DiffMethodToggle/DiffMethodToggle.tsx
61-
- [ ] T011 [US1] Update `App` component: add `diffMethod` state via `useLocalStorage('diffMethod', 'words')`, migrate `viewMode` to `useLocalStorage('viewMode', 'unified')`, pass `diffMethod` to `useDiff` and `DiffMethodToggle`, place `DiffMethodToggle` on left side of diff header in src/components/App/App.tsx
58+
- [x] T008 [P] [US1] Create `DiffMethodToggleProps` interface in src/components/DiffMethodToggle/DiffMethodToggle.types.ts
59+
- [x] T009 [P] [US1] Create barrel export in src/components/DiffMethodToggle/index.ts
60+
- [x] T010 [US1] Implement `DiffMethodToggle` component with 3-button segmented group (Characters | Words | Lines), `role="group"`, `aria-label`, active/inactive Tailwind styling per research.md in src/components/DiffMethodToggle/DiffMethodToggle.tsx
61+
- [x] T011 [US1] Update `App` component: add `diffMethod` state via `useLocalStorage('diffMethod', 'words')`, migrate `viewMode` to `useLocalStorage('viewMode', 'unified')`, pass `diffMethod` to `useDiff` and `DiffMethodToggle`, place `DiffMethodToggle` on left side of diff header in src/components/App/App.tsx
6262

6363
**Checkpoint**: User Story 1 is fully functional — diff method toggle works, selections persist to localStorage, all tests pass.
6464

@@ -68,9 +68,9 @@
6868

6969
**Purpose**: Final quality pass.
7070

71-
- [ ] T012 [P] Accessibility audit — verify `role="group"`, `aria-label` on DiffMethodToggle, keyboard tab order, button focus states
72-
- [ ] T013 Run all quality gates: `npm run lint`, `npm run lint:tsc`, `npm run test:ci`, `npm run build`
73-
- [ ] T014 Run quickstart.md validation — follow all steps in specs/002-toggle-diff-options/quickstart.md and verify they work end-to-end
71+
- [x] T012 [P] Accessibility audit — verify `role="group"`, `aria-label` on DiffMethodToggle, keyboard tab order, button focus states
72+
- [x] T013 Run all quality gates: `npm run lint`, `npm run lint:tsc`, `npm run test:ci`, `npm run build`
73+
- [x] T014 Run quickstart.md validation — follow all steps in specs/002-toggle-diff-options/quickstart.md and verify they work end-to-end
7474

7575
---
7676

src/components/App/App.test.tsx

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('App component', () => {
3737
beforeEach(() => {
3838
const { mockMatchMedia } = createMockMatchMedia(false);
3939
window.matchMedia = mockMatchMedia;
40+
localStorage.clear();
4041
});
4142

4243
afterEach(() => {
@@ -98,19 +99,23 @@ describe('App component', () => {
9899
expect(screen.getByRole('status')).toBeInTheDocument();
99100
});
100101

101-
it('renders "Diff" label when diff output is visible', async () => {
102+
it('renders diff method toggle when diff output is visible', async () => {
102103
const user = userEvent.setup();
103104
render(<App />);
104105

105-
expect(screen.queryByText('Diff')).not.toBeInTheDocument();
106+
expect(
107+
screen.queryByRole('group', { name: 'Diff method' }),
108+
).not.toBeInTheDocument();
106109

107110
const original = screen.getByLabelText('Original Text');
108111
const modified = screen.getByLabelText('Modified Text');
109112

110113
await user.type(original, 'hello');
111114
await user.type(modified, 'hello');
112115

113-
expect(screen.getByText('Diff')).toBeInTheDocument();
116+
expect(
117+
screen.getByRole('group', { name: 'Diff method' }),
118+
).toBeInTheDocument();
114119
});
115120

116121
it('shows diff segments when texts differ', async () => {
@@ -316,4 +321,110 @@ describe('App component', () => {
316321
const addedSpan = diffOutput?.querySelector('.bg-green-100');
317322
expect(addedSpan).toBeInTheDocument();
318323
});
324+
325+
it('defaults to Words diff method', async () => {
326+
const user = userEvent.setup();
327+
render(<App />);
328+
329+
const original = screen.getByLabelText('Original Text');
330+
const modified = screen.getByLabelText('Modified Text');
331+
332+
await user.type(original, 'hello');
333+
await user.type(modified, 'world');
334+
335+
const wordsButton = screen.getByRole('button', { name: 'Words' });
336+
expect(wordsButton.className).toContain('bg-blue-500');
337+
});
338+
339+
it('switches diff output when changing diff method', async () => {
340+
const user = userEvent.setup();
341+
const { container } = render(<App />);
342+
343+
const original = screen.getByLabelText('Original Text');
344+
const modified = screen.getByLabelText('Modified Text');
345+
346+
await user.type(original, 'abc');
347+
await user.type(modified, 'aXc');
348+
349+
const diffOutput = container.querySelector('[aria-live="polite"]');
350+
351+
await user.click(screen.getByRole('button', { name: 'Characters' }));
352+
353+
const removedSpan = diffOutput?.querySelector('.bg-red-100');
354+
expect(removedSpan).toBeInTheDocument();
355+
expect(removedSpan?.textContent).toBe('-b');
356+
357+
const addedSpan = diffOutput?.querySelector('.bg-green-100');
358+
expect(addedSpan).toBeInTheDocument();
359+
expect(addedSpan?.textContent).toBe('+X');
360+
});
361+
362+
it('persists diff method to localStorage', async () => {
363+
const user = userEvent.setup();
364+
render(<App />);
365+
366+
const original = screen.getByLabelText('Original Text');
367+
const modified = screen.getByLabelText('Modified Text');
368+
369+
await user.type(original, 'hello');
370+
await user.type(modified, 'world');
371+
372+
await user.click(screen.getByRole('button', { name: 'Lines' }));
373+
374+
expect(localStorage.getItem('diffMethod')).toBe(JSON.stringify('lines'));
375+
});
376+
377+
it('restores diff method from localStorage on mount', async () => {
378+
localStorage.setItem('diffMethod', JSON.stringify('characters'));
379+
380+
const user = userEvent.setup();
381+
render(<App />);
382+
383+
const original = screen.getByLabelText('Original Text');
384+
const modified = screen.getByLabelText('Modified Text');
385+
386+
await user.type(original, 'hello');
387+
await user.type(modified, 'world');
388+
389+
const charsButton = screen.getByRole('button', { name: 'Characters' });
390+
expect(charsButton.className).toContain('bg-blue-500');
391+
});
392+
393+
it('persists view mode to localStorage', async () => {
394+
const { mockMatchMedia } = createMockMatchMedia(true);
395+
window.matchMedia = mockMatchMedia;
396+
397+
const user = userEvent.setup();
398+
render(<App />);
399+
400+
const original = screen.getByLabelText('Original Text');
401+
const modified = screen.getByLabelText('Modified Text');
402+
403+
await user.type(original, 'hello');
404+
await user.type(modified, 'world');
405+
406+
await user.click(screen.getByRole('button', { name: /side-by-side/i }));
407+
408+
expect(localStorage.getItem('viewMode')).toBe(
409+
JSON.stringify('side-by-side'),
410+
);
411+
});
412+
413+
it('restores view mode from localStorage on mount', async () => {
414+
const { mockMatchMedia } = createMockMatchMedia(true);
415+
window.matchMedia = mockMatchMedia;
416+
localStorage.setItem('viewMode', JSON.stringify('side-by-side'));
417+
418+
const user = userEvent.setup();
419+
const { container } = render(<App />);
420+
421+
const original = screen.getByLabelText('Original Text');
422+
const modified = screen.getByLabelText('Modified Text');
423+
424+
await user.type(original, 'hello');
425+
await user.type(modified, 'world');
426+
427+
const columns = container.querySelectorAll('[data-testid^="diff-column-"]');
428+
expect(columns).toHaveLength(2);
429+
});
319430
});

src/components/App/App.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import { useState } from 'react';
2+
import DiffMethodToggle from 'src/components/DiffMethodToggle';
23
import DiffViewer from 'src/components/DiffViewer';
34
import TextInput from 'src/components/TextInput';
45
import ViewToggle from 'src/components/ViewToggle';
56
import { useDiff } from 'src/hooks/useDiff';
7+
import { useLocalStorage } from 'src/hooks/useLocalStorage';
68
import { useMediaQuery } from 'src/hooks/useMediaQuery';
7-
import type { ViewMode } from 'src/types/diff';
9+
import type { DiffMethod, ViewMode } from 'src/types/diff';
810

911
export default function App() {
1012
const [originalText, setOriginalText] = useState('');
1113
const [modifiedText, setModifiedText] = useState('');
12-
const [viewMode, setViewMode] = useState<ViewMode>('unified');
14+
const [viewMode, setViewMode] = useLocalStorage<ViewMode>(
15+
'viewMode',
16+
'unified',
17+
);
18+
const [diffMethod, setDiffMethod] = useLocalStorage<DiffMethod>(
19+
'diffMethod',
20+
'words',
21+
);
1322

14-
const diffResult = useDiff(originalText, modifiedText);
23+
const diffResult = useDiff(originalText, modifiedText, diffMethod);
1524
const isDesktop = useMediaQuery('(min-width: 768px)');
1625
const effectiveViewMode = isDesktop ? viewMode : 'unified';
1726

@@ -39,9 +48,10 @@ export default function App() {
3948
{diffResult && (
4049
<div className="mt-6">
4150
<div className="mb-1 flex items-center justify-between">
42-
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
43-
Diff
44-
</span>
51+
<DiffMethodToggle
52+
activeMethod={diffMethod}
53+
onMethodChange={setDiffMethod}
54+
/>
4555
<ViewToggle activeMode={viewMode} onModeChange={setViewMode} />
4656
</div>
4757
<DiffViewer result={diffResult} viewMode={effectiveViewMode} />
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import DiffMethodToggle from './DiffMethodToggle';
5+
6+
describe('DiffMethodToggle', () => {
7+
const defaultProps = {
8+
activeMethod: 'words' as const,
9+
onMethodChange: vi.fn(),
10+
};
11+
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
it('renders three buttons for characters, words, and lines', () => {
17+
render(<DiffMethodToggle {...defaultProps} />);
18+
19+
expect(
20+
screen.getByRole('button', { name: 'Characters' }),
21+
).toBeInTheDocument();
22+
expect(screen.getByRole('button', { name: 'Words' })).toBeInTheDocument();
23+
expect(screen.getByRole('button', { name: 'Lines' })).toBeInTheDocument();
24+
});
25+
26+
it('renders a group with accessible label', () => {
27+
render(<DiffMethodToggle {...defaultProps} />);
28+
29+
expect(
30+
screen.getByRole('group', { name: 'Diff method' }),
31+
).toBeInTheDocument();
32+
});
33+
34+
it('highlights the active method button', () => {
35+
render(<DiffMethodToggle {...defaultProps} activeMethod="lines" />);
36+
37+
const linesButton = screen.getByRole('button', { name: 'Lines' });
38+
expect(linesButton.className).toContain('bg-blue-500');
39+
40+
const wordsButton = screen.getByRole('button', { name: 'Words' });
41+
expect(wordsButton.className).not.toContain('bg-blue-500');
42+
});
43+
44+
it('calls onMethodChange with "characters" when Characters is clicked', async () => {
45+
const user = userEvent.setup();
46+
const onMethodChange = vi.fn();
47+
render(
48+
<DiffMethodToggle {...defaultProps} onMethodChange={onMethodChange} />,
49+
);
50+
51+
await user.click(screen.getByRole('button', { name: 'Characters' }));
52+
53+
expect(onMethodChange).toHaveBeenCalledWith('characters');
54+
});
55+
56+
it('calls onMethodChange with "words" when Words is clicked', async () => {
57+
const user = userEvent.setup();
58+
const onMethodChange = vi.fn();
59+
render(
60+
<DiffMethodToggle {...defaultProps} onMethodChange={onMethodChange} />,
61+
);
62+
63+
await user.click(screen.getByRole('button', { name: 'Words' }));
64+
65+
expect(onMethodChange).toHaveBeenCalledWith('words');
66+
});
67+
68+
it('calls onMethodChange with "lines" when Lines is clicked', async () => {
69+
const user = userEvent.setup();
70+
const onMethodChange = vi.fn();
71+
render(
72+
<DiffMethodToggle {...defaultProps} onMethodChange={onMethodChange} />,
73+
);
74+
75+
await user.click(screen.getByRole('button', { name: 'Lines' }));
76+
77+
expect(onMethodChange).toHaveBeenCalledWith('lines');
78+
});
79+
80+
it('is keyboard accessible via Enter key', async () => {
81+
const user = userEvent.setup();
82+
const onMethodChange = vi.fn();
83+
render(
84+
<DiffMethodToggle {...defaultProps} onMethodChange={onMethodChange} />,
85+
);
86+
87+
const charsButton = screen.getByRole('button', { name: 'Characters' });
88+
charsButton.focus();
89+
await user.keyboard('{Enter}');
90+
91+
expect(onMethodChange).toHaveBeenCalledWith('characters');
92+
});
93+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { DiffMethodToggleProps } from './DiffMethodToggle.types';
2+
3+
export default function DiffMethodToggle({
4+
activeMethod,
5+
onMethodChange,
6+
}: DiffMethodToggleProps) {
7+
return (
8+
<div className="flex gap-1" role="group" aria-label="Diff method">
9+
<button
10+
type="button"
11+
onClick={() => {
12+
onMethodChange('characters');
13+
}}
14+
className={`rounded-l-md px-3 py-1.5 text-sm font-medium transition-colors ${
15+
activeMethod === 'characters'
16+
? 'bg-blue-500 text-white dark:bg-blue-600'
17+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
18+
}`}
19+
>
20+
Characters
21+
</button>
22+
<button
23+
type="button"
24+
onClick={() => {
25+
onMethodChange('words');
26+
}}
27+
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
28+
activeMethod === 'words'
29+
? 'bg-blue-500 text-white dark:bg-blue-600'
30+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
31+
}`}
32+
>
33+
Words
34+
</button>
35+
<button
36+
type="button"
37+
onClick={() => {
38+
onMethodChange('lines');
39+
}}
40+
className={`rounded-r-md px-3 py-1.5 text-sm font-medium transition-colors ${
41+
activeMethod === 'lines'
42+
? 'bg-blue-500 text-white dark:bg-blue-600'
43+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
44+
}`}
45+
>
46+
Lines
47+
</button>
48+
</div>
49+
);
50+
}

0 commit comments

Comments
 (0)