Skip to content

Commit 511c1ba

Browse files
committed
refactor(ux): improve pick/discard mode usability and reduce cognitive load
**Simplified Mode Toggle:** - Replace dual buttons with single toggle showing current state - Use descriptive labels: "Choose favorite" vs "Remove unwanted" - Add color-coded indicators (green/red dots) - Less prominent positioning to reduce distraction **Streamlined Instructions:** - Simplified question: "eliminate" instead of "discard" (clearer language) - Removed verbose keyboard shortcuts from main instructions - Unified interaction patterns across card counts **Reduced Information Overload:** - Condensed keyboard hints to essential information only - Delayed appearance of hints to avoid initial distraction - Subtle focus indicators instead of prominent overlays **Improved Accessibility:** - Better aria-labels with current mode context - Added SVG title and role for screen readers - Consistent terminology throughout interface - Clearer visual hierarchy **Code Quality:** - Fixed useEffect dependencies with useCallback - Proper TypeScript type safety - Accessibility linting compliance **Updated Tests:** - Reflect new UI patterns and simplified interactions - Test mode switching with improved selectors
1 parent 6da911f commit 511c1ba

File tree

2 files changed

+108
-131
lines changed

2 files changed

+108
-131
lines changed

src/components/ranking/SwipeComparisonView.tsx

Lines changed: 92 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { AnimatePresence, motion } from 'framer-motion';
4-
import { useEffect, useState } from 'react';
4+
import { useCallback, useEffect, useState } from 'react';
55
import type { Card, Comparison } from '../../types';
66
import { Button } from '../ui/Button';
77
import { SwipeableCard } from './SwipeableCard';
@@ -41,22 +41,40 @@ export function SwipeComparisonView({
4141
return () => clearTimeout(timer);
4242
}, []);
4343

44+
const handleSelection = useCallback(
45+
async (card: Card) => {
46+
setIsSubmitting(true);
47+
setShowInstructions(false);
48+
49+
try {
50+
onSelect(card);
51+
} finally {
52+
setIsSubmitting(false);
53+
setSelectedCard(null);
54+
}
55+
},
56+
[onSelect]
57+
);
58+
4459
// Helper function to execute the current mode action
45-
const executeAction = (card: Card) => {
46-
if (mode === 'pick') {
47-
handleSelection(card);
48-
} else {
49-
// In discard mode, select the other card(s)
50-
const otherCards = comparison.cards.filter(c => c.id !== card.id);
51-
if (otherCards.length === 1) {
52-
handleSelection(otherCards[0]);
53-
} else if (otherCards.length > 1) {
54-
// For multi-card comparisons in discard mode, we'd need more complex logic
55-
// For now, just pick the first non-discarded card
56-
handleSelection(otherCards[0]);
60+
const executeAction = useCallback(
61+
(card: Card) => {
62+
if (mode === 'pick') {
63+
handleSelection(card);
64+
} else {
65+
// In discard mode, select the other card(s)
66+
const otherCards = comparison.cards.filter(c => c.id !== card.id);
67+
if (otherCards.length === 1) {
68+
handleSelection(otherCards[0]);
69+
} else if (otherCards.length > 1) {
70+
// For multi-card comparisons in discard mode, we'd need more complex logic
71+
// For now, just pick the first non-discarded card
72+
handleSelection(otherCards[0]);
73+
}
5774
}
58-
}
59-
};
75+
},
76+
[mode, comparison.cards, handleSelection]
77+
);
6078

6179
// Keyboard navigation
6280
useEffect(() => {
@@ -135,7 +153,18 @@ export function SwipeComparisonView({
135153

136154
document.addEventListener('keydown', handleKeyDown);
137155
return () => document.removeEventListener('keydown', handleKeyDown);
138-
}, [disabled, isSubmitting, selectedCard, focusedCardIndex, comparison.cards, mode]);
156+
}, [
157+
disabled,
158+
isSubmitting,
159+
selectedCard,
160+
focusedCardIndex,
161+
comparison.cards,
162+
executeAction,
163+
handleSelection,
164+
setMode,
165+
setFocusedCardIndex,
166+
setSelectedCard,
167+
]);
139168

140169
const handleCardSwipeRight = (card: Card) => {
141170
if (disabled || isSubmitting) return;
@@ -157,18 +186,6 @@ export function SwipeComparisonView({
157186
setSelectedCard(card);
158187
};
159188

160-
const handleSelection = async (card: Card) => {
161-
setIsSubmitting(true);
162-
setShowInstructions(false);
163-
164-
try {
165-
onSelect(card);
166-
} finally {
167-
setIsSubmitting(false);
168-
setSelectedCard(null);
169-
}
170-
};
171-
172189
const handleConfirm = () => {
173190
if (selectedCard) {
174191
handleSelection(selectedCard);
@@ -203,32 +220,43 @@ export function SwipeComparisonView({
203220
</div>
204221
)}
205222

206-
{/* Mode toggle */}
223+
{/* Mode toggle - simplified and less prominent */}
207224
<div className="flex items-center justify-center">
208-
<div className="bg-muted p-1 rounded-lg flex gap-1">
225+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
226+
<span>Mode:</span>
209227
<button
210228
type="button"
211-
onClick={() => setMode('pick')}
212-
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${
213-
mode === 'pick'
214-
? 'bg-primary text-primary-foreground shadow-sm'
215-
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
216-
}`}
217-
aria-pressed={mode === 'pick'}
229+
onClick={() => setMode(mode === 'pick' ? 'discard' : 'pick')}
230+
className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-muted hover:bg-muted/80 transition-colors"
231+
aria-label={`Switch to ${mode === 'pick' ? 'discard' : 'pick'} mode. Currently in ${mode} mode.`}
218232
>
219-
Pick Mode
220-
</button>
221-
<button
222-
type="button"
223-
onClick={() => setMode('discard')}
224-
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${
225-
mode === 'discard'
226-
? 'bg-primary text-primary-foreground shadow-sm'
227-
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
228-
}`}
229-
aria-pressed={mode === 'discard'}
230-
>
231-
Discard Mode
233+
{mode === 'pick' ? (
234+
<>
235+
<span className="w-2 h-2 rounded-full bg-green-500" />
236+
Choose favorite
237+
</>
238+
) : (
239+
<>
240+
<span className="w-2 h-2 rounded-full bg-red-500" />
241+
Remove unwanted
242+
</>
243+
)}
244+
<svg
245+
className="w-3 h-3"
246+
fill="none"
247+
stroke="currentColor"
248+
viewBox="0 0 24 24"
249+
role="img"
250+
aria-label="Switch mode"
251+
>
252+
<title>Switch mode</title>
253+
<path
254+
strokeLinecap="round"
255+
strokeLinejoin="round"
256+
strokeWidth={2}
257+
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
258+
/>
259+
</svg>
232260
</button>
233261
</div>
234262
</div>
@@ -244,35 +272,13 @@ export function SwipeComparisonView({
244272
transition={{ duration: 0.3 }}
245273
>
246274
<h2 className="text-2xl font-semibold">
247-
{mode === 'pick' ? 'Which do you prefer?' : 'Which do you want to discard?'}
275+
{mode === 'pick' ? 'Which do you prefer?' : 'Which would you eliminate?'}
248276
</h2>
249277
<p className="text-muted-foreground">
250278
{comparison.cards.length === 2 ? (
251-
mode === 'pick' ? (
252-
<>
253-
Use <kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd>{' '}
254-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd> to pick, or tap to
255-
select manually
256-
</>
257-
) : (
258-
<>
259-
Use <kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd>{' '}
260-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd> to discard, or tap
261-
to select manually
262-
</>
263-
)
264-
) : mode === 'pick' ? (
265-
<>
266-
Use <kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd>{' '}
267-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd> to navigate,{' '}
268-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs">Space</kbd> to pick
269-
</>
279+
<>Use arrow keys or tap to choose</>
270280
) : (
271-
<>
272-
Use <kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd>{' '}
273-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs"></kbd> to navigate,{' '}
274-
<kbd className="px-1 py-0.5 bg-muted rounded text-xs">Space</kbd> to discard
275-
</>
281+
<>Use arrow keys to browse, Space to select</>
276282
)}
277283
</p>
278284
</motion.div>
@@ -319,16 +325,16 @@ export function SwipeComparisonView({
319325
/>
320326
</button>
321327

322-
{/* Focus indicator for multi-card comparisons */}
328+
{/* Subtle focus indicator for multi-card comparisons */}
323329
{comparison.cards.length > 2 && focusedCardIndex === index && (
324330
<motion.div
325-
className="absolute top-2 right-2 bg-primary text-primary-foreground px-2 py-1 rounded-md text-xs font-medium shadow-lg"
331+
className="absolute top-2 right-2 bg-background/80 backdrop-blur-sm text-foreground px-2 py-1 rounded text-xs font-medium shadow-sm border"
326332
initial={{ scale: 0, opacity: 0 }}
327-
animate={{ scale: 1, opacity: 1 }}
333+
animate={{ scale: 1, opacity: 0.9 }}
328334
exit={{ scale: 0, opacity: 0 }}
329335
transition={{ duration: 0.2 }}
330336
>
331-
Press Space to {mode}
337+
Space to {mode === 'pick' ? 'choose' : 'eliminate'}
332338
</motion.div>
333339
)}
334340
</motion.div>
@@ -392,24 +398,18 @@ export function SwipeComparisonView({
392398
</div>
393399
)}
394400

395-
{/* Keyboard shortcuts hint */}
396-
<motion.section
397-
className="text-center text-xs text-muted-foreground space-y-1"
401+
{/* Simplified keyboard hints */}
402+
<motion.div
403+
className="text-center text-xs text-muted-foreground"
398404
initial={{ opacity: 0 }}
399405
animate={{ opacity: 1 }}
400-
transition={{ delay: 1 }}
401-
aria-label="Keyboard shortcuts"
406+
transition={{ delay: 2 }}
402407
>
403408
<div>
404-
{comparison.cards.length === 2
405-
? `${mode === 'pick' ? 'Pick' : 'Discard'} mode: ← → arrows to ${mode} • P/D to switch mode • Enter to confirm • Esc to cancel`
406-
: `${mode === 'pick' ? 'Pick' : 'Discard'} mode: ← → to navigate • Space to ${mode} • P/D to switch mode • Enter to confirm`}
407-
</div>
408-
<div>
409-
Quick select: 1 for left option • 2 for right option • P for pick mode • D for discard
410-
mode
409+
Keyboard: {comparison.cards.length === 2 ? '← → to choose' : '← → Space to select'} • P/D
410+
to switch mode
411411
</div>
412-
</motion.section>
412+
</motion.div>
413413

414414
{/* Loading overlay */}
415415
<AnimatePresence>

tests/e2e/pick-discard-mode.spec.ts

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,34 +20,20 @@ test.describe('Pick/Discard Mode', () => {
2020
await page.click('[data-testid="pack-card-Pick Discard Test Pack"]');
2121
await page.click('button:has-text("Start Ranking")');
2222

23-
// Check default mode is pick
24-
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
25-
'aria-pressed',
26-
'true'
27-
);
28-
await expect(page.locator('button:has-text("Discard Mode")')).toHaveAttribute(
29-
'aria-pressed',
30-
'false'
31-
);
23+
// Check default mode shows "Choose favorite"
24+
await expect(page.locator('button').filter({ hasText: 'Choose favorite' })).toBeVisible();
3225

3326
// Check instructions show pick mode
3427
await expect(page.locator('h2')).toContainText('Which do you prefer?');
3528

36-
// Switch to discard mode
37-
await page.click('button:has-text("Discard Mode")');
29+
// Switch to discard mode by clicking the mode toggle
30+
await page.click('button:has-text("Choose favorite")');
3831

39-
// Check mode switched
40-
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
41-
'aria-pressed',
42-
'false'
43-
);
44-
await expect(page.locator('button:has-text("Discard Mode")')).toHaveAttribute(
45-
'aria-pressed',
46-
'true'
47-
);
32+
// Check mode switched to "Remove unwanted"
33+
await expect(page.locator('button').filter({ hasText: 'Remove unwanted' })).toBeVisible();
4834

4935
// Check instructions changed
50-
await expect(page.locator('h2')).toContainText('Which do you want to discard?');
36+
await expect(page.locator('h2')).toContainText('Which would you eliminate?');
5137
});
5238

5339
test('should use keyboard shortcuts to switch modes', async ({ page }) => {
@@ -69,26 +55,17 @@ test.describe('Pick/Discard Mode', () => {
6955
await page.click('[data-testid="pack-card-Keyboard Test Pack"]');
7056
await page.click('button:has-text("Start Ranking")');
7157

72-
// Initially in pick mode
73-
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
74-
'aria-pressed',
75-
'true'
76-
);
58+
// Initially in pick mode - check for "Choose favorite"
59+
await expect(page.locator('button').filter({ hasText: 'Choose favorite' })).toBeVisible();
7760

7861
// Press 'D' to switch to discard mode
7962
await page.keyboard.press('d');
80-
await expect(page.locator('button:has-text("Discard Mode")')).toHaveAttribute(
81-
'aria-pressed',
82-
'true'
83-
);
84-
await expect(page.locator('h2')).toContainText('discard');
63+
await expect(page.locator('button').filter({ hasText: 'Remove unwanted' })).toBeVisible();
64+
await expect(page.locator('h2')).toContainText('eliminate');
8565

8666
// Press 'P' to switch back to pick mode
8767
await page.keyboard.press('p');
88-
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
89-
'aria-pressed',
90-
'true'
91-
);
68+
await expect(page.locator('button').filter({ hasText: 'Choose favorite' })).toBeVisible();
9269
await expect(page.locator('h2')).toContainText('prefer');
9370
});
9471

@@ -117,11 +94,11 @@ test.describe('Pick/Discard Mode', () => {
11794
await page.click('[data-testid="pack-card-Multi Card Test Pack"]');
11895
await page.click('button:has-text("Start Ranking")');
11996

120-
// For 2-card comparison, should show "← → arrows to pick"
121-
const keyboardHint = page.locator('div').filter({ hasText: /Pick mode:.*arrows to pick/ });
97+
// For 2-card comparison, should show simplified "← → to choose"
98+
const keyboardHint = page.locator('div').filter({ hasText: / to choose/ });
12299
await expect(keyboardHint).toBeVisible();
123100

124-
// Check that it mentions arrows directly performing action (not navigation)
125-
await expect(keyboardHint).toContainText('arrows to pick');
101+
// Check simplified keyboard hints
102+
await expect(keyboardHint).toContainText('← → to choose');
126103
});
127104
});

0 commit comments

Comments
 (0)