Skip to content

Commit 6da911f

Browse files
committed
feat(ranking): implement pick/discard mode switching with keyboard controls
- Add pick/discard mode toggle with visual UI buttons - Implement context-sensitive keyboard controls: - 2 cards: left/right arrows perform current action directly - 3+ cards: left/right navigate, space performs action - Add keyboard shortcuts P/D to switch between modes - Update instructions dynamically based on mode and card count - Add visual focus indicators for multi-card navigation - Include comprehensive E2E tests for mode switching functionality
1 parent e826844 commit 6da911f

File tree

2 files changed

+273
-14
lines changed

2 files changed

+273
-14
lines changed

src/components/ranking/SwipeComparisonView.tsx

Lines changed: 146 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { Card, Comparison } from '../../types';
66
import { Button } from '../ui/Button';
77
import { SwipeableCard } from './SwipeableCard';
88

9+
type ComparisonMode = 'pick' | 'discard';
10+
911
interface SwipeComparisonViewProps {
1012
comparison: Comparison;
1113
onSelect: (winner: Card) => void;
@@ -28,6 +30,7 @@ export function SwipeComparisonView({
2830
const [isSubmitting, setIsSubmitting] = useState(false);
2931
const [showInstructions, setShowInstructions] = useState(true);
3032
const [focusedCardIndex, setFocusedCardIndex] = useState<number>(0);
33+
const [mode, setMode] = useState<ComparisonMode>('pick');
3134

3235
// Hide instructions after first interaction
3336
useEffect(() => {
@@ -38,6 +41,23 @@ export function SwipeComparisonView({
3841
return () => clearTimeout(timer);
3942
}, []);
4043

44+
// 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]);
57+
}
58+
}
59+
};
60+
4161
// Keyboard navigation
4262
useEffect(() => {
4363
const handleKeyDown = (event: KeyboardEvent) => {
@@ -46,45 +66,76 @@ export function SwipeComparisonView({
4666
switch (event.key) {
4767
case 'ArrowLeft':
4868
event.preventDefault();
49-
setFocusedCardIndex(0);
50-
setSelectedCard(comparison.cards[0]);
69+
if (comparison.cards.length === 2) {
70+
// With 2 cards: left arrow performs current action on left card
71+
executeAction(comparison.cards[0]);
72+
} else {
73+
// With more cards: left arrow navigates to previous card
74+
setFocusedCardIndex(
75+
prev => (prev - 1 + comparison.cards.length) % comparison.cards.length
76+
);
77+
}
5178
break;
5279
case 'ArrowRight':
5380
event.preventDefault();
54-
setFocusedCardIndex(1);
55-
setSelectedCard(comparison.cards[1]);
81+
if (comparison.cards.length === 2) {
82+
// With 2 cards: right arrow performs current action on right card
83+
executeAction(comparison.cards[1]);
84+
} else {
85+
// With more cards: right arrow navigates to next card
86+
setFocusedCardIndex(prev => (prev + 1) % comparison.cards.length);
87+
}
88+
break;
89+
case ' ':
90+
event.preventDefault();
91+
if (comparison.cards.length > 2) {
92+
// With more cards: space performs action on focused card
93+
executeAction(comparison.cards[focusedCardIndex]);
94+
} else {
95+
// With 2 cards: space cancels selection
96+
setSelectedCard(null);
97+
}
5698
break;
5799
case 'Enter':
58100
event.preventDefault();
59101
if (selectedCard) {
60102
handleSelection(selectedCard);
61103
} else if (comparison.cards[focusedCardIndex]) {
62-
handleSelection(comparison.cards[focusedCardIndex]);
104+
executeAction(comparison.cards[focusedCardIndex]);
63105
}
64106
break;
65-
case ' ':
66107
case 'Escape':
67108
event.preventDefault();
68109
setSelectedCard(null);
69110
break;
111+
case 'p':
112+
case 'P':
113+
event.preventDefault();
114+
setMode('pick');
115+
break;
116+
case 'd':
117+
case 'D':
118+
event.preventDefault();
119+
setMode('discard');
120+
break;
70121
case '1':
71122
event.preventDefault();
72123
if (comparison.cards[0]) {
73-
handleSelection(comparison.cards[0]);
124+
executeAction(comparison.cards[0]);
74125
}
75126
break;
76127
case '2':
77128
event.preventDefault();
78129
if (comparison.cards[1]) {
79-
handleSelection(comparison.cards[1]);
130+
executeAction(comparison.cards[1]);
80131
}
81132
break;
82133
}
83134
};
84135

85136
document.addEventListener('keydown', handleKeyDown);
86137
return () => document.removeEventListener('keydown', handleKeyDown);
87-
}, [disabled, isSubmitting, selectedCard, focusedCardIndex, comparison.cards]);
138+
}, [disabled, isSubmitting, selectedCard, focusedCardIndex, comparison.cards, mode]);
88139

89140
const handleCardSwipeRight = (card: Card) => {
90141
if (disabled || isSubmitting) return;
@@ -152,6 +203,36 @@ export function SwipeComparisonView({
152203
</div>
153204
)}
154205

206+
{/* Mode toggle */}
207+
<div className="flex items-center justify-center">
208+
<div className="bg-muted p-1 rounded-lg flex gap-1">
209+
<button
210+
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'}
218+
>
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
232+
</button>
233+
</div>
234+
</div>
235+
155236
{/* Instructions */}
156237
<AnimatePresence>
157238
{showInstructions && (
@@ -162,9 +243,37 @@ export function SwipeComparisonView({
162243
exit={{ opacity: 0, y: -20 }}
163244
transition={{ duration: 0.3 }}
164245
>
165-
<h2 className="text-2xl font-semibold">Which do you prefer?</h2>
246+
<h2 className="text-2xl font-semibold">
247+
{mode === 'pick' ? 'Which do you prefer?' : 'Which do you want to discard?'}
248+
</h2>
166249
<p className="text-muted-foreground">
167-
Swipe right to choose, swipe left to prefer the other, or tap to select manually
250+
{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+
</>
270+
) : (
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+
</>
276+
)}
168277
</p>
169278
</motion.div>
170279
)}
@@ -184,6 +293,7 @@ export function SwipeComparisonView({
184293
type: 'spring',
185294
stiffness: 100,
186295
}}
296+
className="relative"
187297
>
188298
<button
189299
type="button"
@@ -192,7 +302,9 @@ export function SwipeComparisonView({
192302
onClick={() => handleCardTap(card)}
193303
onFocus={() => setFocusedCardIndex(index)}
194304
className={`w-full p-0 border-0 bg-transparent focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-lg ${
195-
focusedCardIndex === index ? 'ring-2 ring-primary/50' : ''
305+
focusedCardIndex === index && comparison.cards.length > 2
306+
? 'ring-2 ring-primary/70'
307+
: ''
196308
}`}
197309
>
198310
<SwipeableCard
@@ -206,6 +318,19 @@ export function SwipeComparisonView({
206318
} ${focusedCardIndex === index ? 'shadow-lg' : ''}`}
207319
/>
208320
</button>
321+
322+
{/* Focus indicator for multi-card comparisons */}
323+
{comparison.cards.length > 2 && focusedCardIndex === index && (
324+
<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"
326+
initial={{ scale: 0, opacity: 0 }}
327+
animate={{ scale: 1, opacity: 1 }}
328+
exit={{ scale: 0, opacity: 0 }}
329+
transition={{ duration: 0.2 }}
330+
>
331+
Press Space to {mode}
332+
</motion.div>
333+
)}
209334
</motion.div>
210335
))}
211336
</fieldset>
@@ -275,8 +400,15 @@ export function SwipeComparisonView({
275400
transition={{ delay: 1 }}
276401
aria-label="Keyboard shortcuts"
277402
>
278-
<div>Keyboard shortcuts: ← → arrows to select • Enter to confirm • Space/Esc to cancel</div>
279-
<div>Quick select: 1 for left option • 2 for right option</div>
403+
<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
411+
</div>
280412
</motion.section>
281413

282414
{/* Loading overlay */}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Pick/Discard Mode', () => {
4+
test('should toggle between pick and discard modes with buttons', async ({ page }) => {
5+
// Create a pack with 2 items
6+
await page.goto('http://localhost:3000/create');
7+
8+
// Add pack name
9+
await page.fill('[data-testid="pack-name-input"]', 'Pick Discard Test Pack');
10+
11+
// Add 2 items
12+
await page.fill('[data-testid="card-content-0"]', 'Option A');
13+
await page.click('[data-testid="add-card-button"]');
14+
await page.fill('[data-testid="card-content-1"]', 'Option B');
15+
16+
// Create pack
17+
await page.click('button:has-text("Create Pack")');
18+
19+
// Navigate to ranking
20+
await page.click('[data-testid="pack-card-Pick Discard Test Pack"]');
21+
await page.click('button:has-text("Start Ranking")');
22+
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+
);
32+
33+
// Check instructions show pick mode
34+
await expect(page.locator('h2')).toContainText('Which do you prefer?');
35+
36+
// Switch to discard mode
37+
await page.click('button:has-text("Discard Mode")');
38+
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+
);
48+
49+
// Check instructions changed
50+
await expect(page.locator('h2')).toContainText('Which do you want to discard?');
51+
});
52+
53+
test('should use keyboard shortcuts to switch modes', async ({ page }) => {
54+
// Create a pack with 2 items
55+
await page.goto('http://localhost:3000/create');
56+
57+
// Add pack name
58+
await page.fill('[data-testid="pack-name-input"]', 'Keyboard Test Pack');
59+
60+
// Add 2 items
61+
await page.fill('[data-testid="card-content-0"]', 'Item 1');
62+
await page.click('[data-testid="add-card-button"]');
63+
await page.fill('[data-testid="card-content-1"]', 'Item 2');
64+
65+
// Create pack
66+
await page.click('button:has-text("Create Pack")');
67+
68+
// Navigate to ranking
69+
await page.click('[data-testid="pack-card-Keyboard Test Pack"]');
70+
await page.click('button:has-text("Start Ranking")');
71+
72+
// Initially in pick mode
73+
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
74+
'aria-pressed',
75+
'true'
76+
);
77+
78+
// Press 'D' to switch to discard mode
79+
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');
85+
86+
// Press 'P' to switch back to pick mode
87+
await page.keyboard.press('p');
88+
await expect(page.locator('button:has-text("Pick Mode")')).toHaveAttribute(
89+
'aria-pressed',
90+
'true'
91+
);
92+
await expect(page.locator('h2')).toContainText('prefer');
93+
});
94+
95+
test('should show different keyboard shortcuts for 2-card vs multi-card comparisons', async ({
96+
page,
97+
}) => {
98+
// Create a pack with 4 items to test multi-card behavior
99+
await page.goto('http://localhost:3000/create');
100+
101+
// Add pack name
102+
await page.fill('[data-testid="pack-name-input"]', 'Multi Card Test Pack');
103+
104+
// Add 4 items
105+
await page.fill('[data-testid="card-content-0"]', 'Card 1');
106+
await page.click('[data-testid="add-card-button"]');
107+
await page.fill('[data-testid="card-content-1"]', 'Card 2');
108+
await page.click('[data-testid="add-card-button"]');
109+
await page.fill('[data-testid="card-content-2"]', 'Card 3');
110+
await page.click('[data-testid="add-card-button"]');
111+
await page.fill('[data-testid="card-content-3"]', 'Card 4');
112+
113+
// Create pack
114+
await page.click('button:has-text("Create Pack")');
115+
116+
// Navigate to ranking
117+
await page.click('[data-testid="pack-card-Multi Card Test Pack"]');
118+
await page.click('button:has-text("Start Ranking")');
119+
120+
// For 2-card comparison, should show "← → arrows to pick"
121+
const keyboardHint = page.locator('div').filter({ hasText: /Pick mode:.*arrows to pick/ });
122+
await expect(keyboardHint).toBeVisible();
123+
124+
// Check that it mentions arrows directly performing action (not navigation)
125+
await expect(keyboardHint).toContainText('arrows to pick');
126+
});
127+
});

0 commit comments

Comments
 (0)