fix(scanner): close redesign a11y audit findings
Add focus traps for modals, accessible names for icon/select controls, ownership badge role=status, list semantics for the scan queue, and aria-live updates for the card count. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ff73743a9f
commit
5a1a7295d1
6 changed files with 162 additions and 36 deletions
|
|
@ -24,7 +24,7 @@ Reports posted to [PR #44](https://github.com/varutasu/tcg-vault/pull/44#issueco
|
||||||
|
|
||||||
| Slug | Priority | Source |
|
| Slug | Priority | Source |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `scanner-redesign-a11y-fixes` | P1 | A11y audit — ownership badge role, modal focus traps, unlabeled selects/buttons |
|
| `scanner-redesign-a11y-fixes` | P1 | **In progress** — PR pending |
|
||||||
| `scanner-user-cards-quantity-guard` | P2 | Reviewer — parseInt/NaN guard parity with decks handler |
|
| `scanner-user-cards-quantity-guard` | P2 | Reviewer — parseInt/NaN guard parity with decks handler |
|
||||||
| `test-scanner-redesign-surfaces` | P2 | Reviewer — unit tests for new components + upload route |
|
| `test-scanner-redesign-surfaces` | P2 | Reviewer — unit tests for new components + upload route |
|
||||||
| `document-condition-foil-destination-semantics` | P3 | Reviewer — clarify or migrate condition/foil for collection/deck rows |
|
| `document-condition-foil-destination-semantics` | P3 | Reviewer — clarify or migrate condition/foil for collection/deck rows |
|
||||||
|
|
|
||||||
30
.convoys/scanner-redesign-a11y-fixes.md
Normal file
30
.convoys/scanner-redesign-a11y-fixes.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
name: scanner-redesign-a11y-fixes
|
||||||
|
classification: fix
|
||||||
|
success_metric: |
|
||||||
|
Post-audit a11y gaps from audit-redesign-scanner-flow-44 closed: ownership
|
||||||
|
badge exposed to AT, icon/select controls labeled, modals trap focus, queue
|
||||||
|
uses list semantics and live count updates.
|
||||||
|
depends_on:
|
||||||
|
- redesign-scanner-flow
|
||||||
|
status: open
|
||||||
|
created: 2026-05-27
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convoy: scanner-redesign-a11y-fixes
|
||||||
|
|
||||||
|
Closes P1 follow-up from `audit-redesign-scanner-flow-44`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `components/ScannedCardItem.js` — ownership `role="status"`, labeled selects/buttons
|
||||||
|
- `components/CameraScanner.js` — camera control labels, disambiguation focus trap, video label, heading fix
|
||||||
|
- `pages/scanner.js` — create-collection modal a11y, bulk toolbar labels, queue list semantics, live count
|
||||||
|
- `lib/use-focus-trap.js` (new) — shared modal focus trap
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. Ownership badge announced by screen readers.
|
||||||
|
2. All icon-only and placeholder `<select>` controls have accessible names.
|
||||||
|
3. Disambiguation + create-collection modals trap focus and restore on close.
|
||||||
|
4. Scanned cards queue uses list semantics; count updates are polite live region.
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
||||||
|
|
||||||
async function uploadScanCapture(imageData) {
|
async function uploadScanCapture(imageData) {
|
||||||
const response = await fetch('/api/scan/upload-image', {
|
const response = await fetch('/api/scan/upload-image', {
|
||||||
|
|
@ -46,6 +47,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
const activeVerificationRef = useRef(0);
|
const activeVerificationRef = useRef(0);
|
||||||
const lastErrorAtRef = useRef(0);
|
const lastErrorAtRef = useRef(0);
|
||||||
const disambiguationRefineRef = useRef(null);
|
const disambiguationRefineRef = useRef(null);
|
||||||
|
const disambiguationDialogRef = useFocusTrap(Boolean(disambiguation));
|
||||||
|
|
||||||
// Mana symbol settings
|
// Mana symbol settings
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
@ -826,6 +828,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
|
aria-label="Card scanner camera feed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Card Detection Overlays - Only when streaming */}
|
{/* Card Detection Overlays - Only when streaming */}
|
||||||
|
|
@ -907,9 +910,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||||
border: '3px solid rgba(255, 255, 255, 0.3)'
|
border: '3px solid rgba(255, 255, 255, 0.3)'
|
||||||
}}
|
}}
|
||||||
title="Stop Camera"
|
aria-label="Stop camera"
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 bg-white rounded-sm"></div>
|
<div className="w-6 h-6 bg-white rounded-sm" aria-hidden="true"></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -945,6 +948,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
backgroundColor: 'var(--accent-ember)',
|
backgroundColor: 'var(--accent-ember)',
|
||||||
border: '4px solid rgba(255, 255, 255, 0.2)'
|
border: '4px solid rgba(255, 255, 255, 0.2)'
|
||||||
}}
|
}}
|
||||||
|
aria-label="Start camera"
|
||||||
>
|
>
|
||||||
{/* Button Glow Effect */}
|
{/* Button Glow Effect */}
|
||||||
<div className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
<div className="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
|
|
@ -953,7 +957,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
}}></div>
|
}}></div>
|
||||||
|
|
||||||
{/* Play Icon */}
|
{/* Play Icon */}
|
||||||
<div className="relative z-10">
|
<div className="relative z-10" aria-hidden="true">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -999,9 +1003,9 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
<div className="w-6 h-6 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
<div className="w-6 h-6 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
|
||||||
<span className="text-white text-xs">🎯</span>
|
<span className="text-white text-xs">🎯</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
<h3 className="font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
Smart Detection Active
|
Smart Detection Active
|
||||||
</h4>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
<div className="grid grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1027,6 +1031,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
||||||
{disambiguation && (
|
{disambiguation && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||||
<div
|
<div
|
||||||
|
ref={disambiguationDialogRef}
|
||||||
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
|
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
|
||||||
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
|
||||||
|
|
@ -109,11 +109,13 @@ export default function ScannedCardItem({
|
||||||
</h3>
|
</h3>
|
||||||
{card.isExisting && (
|
{card.isExisting && (
|
||||||
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
|
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
|
||||||
✅ Found in database
|
<span aria-hidden="true">✅ </span>
|
||||||
|
<span>Found in database</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ownedQuantity !== null && ownedQuantity > 0 && (
|
{ownedQuantity !== null && ownedQuantity > 0 && (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
className="text-xs font-medium mt-1"
|
className="text-xs font-medium mt-1"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
aria-label={`You already own ${ownedQuantity} copies of this card`}
|
aria-label={`You already own ${ownedQuantity} copies of this card`}
|
||||||
|
|
@ -218,16 +220,17 @@ export default function ScannedCardItem({
|
||||||
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
|
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
|
||||||
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||||||
>
|
>
|
||||||
💎 Mark Owned
|
<span aria-hidden="true">💎 </span>
|
||||||
|
Mark Owned
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="px-3 py-2 rounded text-sm hover:opacity-80"
|
className="px-3 py-2 rounded text-sm hover:opacity-80"
|
||||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
|
||||||
title="Remove"
|
aria-label={`Remove ${card.name} from scan queue`}
|
||||||
>
|
>
|
||||||
🗑️
|
<span aria-hidden="true">🗑️</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -246,6 +249,7 @@ export default function ScannedCardItem({
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
}}
|
}}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
|
aria-label={`Add ${card.name} to a collection`}
|
||||||
>
|
>
|
||||||
<option value="">📚 Add to Collection</option>
|
<option value="">📚 Add to Collection</option>
|
||||||
{collections.map((collection) => (
|
{collections.map((collection) => (
|
||||||
|
|
@ -270,6 +274,7 @@ export default function ScannedCardItem({
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
}}
|
}}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
|
aria-label={`Add ${card.name} to a deck`}
|
||||||
>
|
>
|
||||||
<option value="">🃏 Add to Deck</option>
|
<option value="">🃏 Add to Deck</option>
|
||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
|
|
|
||||||
64
lib/use-focus-trap.js
Normal file
64
lib/use-focus-trap.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
function getFocusableElements(container) {
|
||||||
|
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||||
|
(el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trap focus inside a modal while `active` and restore focus on close.
|
||||||
|
* Returns a ref to attach to the dialog panel (not the backdrop).
|
||||||
|
*/
|
||||||
|
export function useFocusTrap(active) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const previouslyFocusedRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
previouslyFocusedRef.current = document.activeElement;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const focusFirst = () => {
|
||||||
|
const nodes = getFocusableElements(container);
|
||||||
|
nodes[0]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
focusFirst();
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const nodes = getFocusableElements(container);
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
const first = nodes[0];
|
||||||
|
const last = nodes[nodes.length - 1];
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
const previous = previouslyFocusedRef.current;
|
||||||
|
if (previous && typeof previous.focus === 'function') {
|
||||||
|
previous.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return containerRef;
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import OCRSettings from '../components/OCRSettings';
|
||||||
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
import { useFocusTrap } from '../lib/use-focus-trap.js';
|
||||||
|
|
||||||
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ export default function Scanner() {
|
||||||
const [decks, setDecks] = useState([]);
|
const [decks, setDecks] = useState([]);
|
||||||
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
const [showCreateCollection, setShowCreateCollection] = useState(false);
|
||||||
const [newCollectionName, setNewCollectionName] = useState('');
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
|
const createCollectionDialogRef = useFocusTrap(showCreateCollection);
|
||||||
const [showOCRSettings, setShowOCRSettings] = useState(false);
|
const [showOCRSettings, setShowOCRSettings] = useState(false);
|
||||||
|
|
||||||
// Bulk action states
|
// Bulk action states
|
||||||
|
|
@ -560,7 +562,12 @@ export default function Scanner() {
|
||||||
Scanned Cards
|
Scanned Cards
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<div
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
{scannedCards.length} cards
|
{scannedCards.length} cards
|
||||||
</div>
|
</div>
|
||||||
{scannedCards.length > 0 && (
|
{scannedCards.length > 0 && (
|
||||||
|
|
@ -580,33 +587,34 @@ export default function Scanner() {
|
||||||
|
|
||||||
{/* Scanned Cards Queue - Scrollable */}
|
{/* Scanned Cards Queue - Scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-3">
|
|
||||||
{scannedCards.length === 0 ? (
|
{scannedCards.length === 0 ? (
|
||||||
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<div className="text-4xl mb-2">📱</div>
|
<div className="text-4xl mb-2" aria-hidden="true">📱</div>
|
||||||
<div className="font-medium">No cards scanned yet</div>
|
<div className="font-medium">No cards scanned yet</div>
|
||||||
<div className="text-sm">Start scanning to see cards here</div>
|
<div className="text-sm">Start scanning to see cards here</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
scannedCards.map((card) => (
|
<ul className="space-y-3 list-none p-0 m-0" aria-label="Scanned cards queue">
|
||||||
<ScannedCardItem
|
{scannedCards.map((card) => (
|
||||||
key={card.id}
|
<li key={card.id}>
|
||||||
card={card}
|
<ScannedCardItem
|
||||||
collections={collections}
|
card={card}
|
||||||
decks={decks}
|
collections={collections}
|
||||||
selected={selectedCards.has(card.id)}
|
decks={decks}
|
||||||
onToggleSelect={() => toggleCardSelection(card.id)}
|
selected={selectedCards.has(card.id)}
|
||||||
onIncrement={() => incrementCardQuantity(card.id)}
|
onToggleSelect={() => toggleCardSelection(card.id)}
|
||||||
onDecrement={() => decrementCardQuantity(card.id)}
|
onIncrement={() => incrementCardQuantity(card.id)}
|
||||||
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
onDecrement={() => decrementCardQuantity(card.id)}
|
||||||
onMarkOwned={() => addSingleCardToOwned(card)}
|
onUpdateMetadata={(patch) => updateCardMetadata(card.id, patch)}
|
||||||
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
onMarkOwned={() => addSingleCardToOwned(card)}
|
||||||
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
onAddToCollection={(collectionId) => addSingleCardToCollection(card, collectionId)}
|
||||||
onRemove={() => removeScannedCard(card.id)}
|
onAddToDeck={(deckId) => addSingleCardToDeck(card, deckId)}
|
||||||
/>
|
onRemove={() => removeScannedCard(card.id)}
|
||||||
))
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -644,7 +652,8 @@ export default function Scanner() {
|
||||||
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
|
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
|
||||||
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
|
||||||
>
|
>
|
||||||
💎 Mark Owned
|
<span aria-hidden="true">💎 </span>
|
||||||
|
Mark Owned
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{collections.length > 0 && (
|
{collections.length > 0 && (
|
||||||
|
|
@ -659,6 +668,7 @@ export default function Scanner() {
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="px-4 py-2 rounded-lg font-medium"
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
|
||||||
|
aria-label="Add selected cards to collection"
|
||||||
>
|
>
|
||||||
<option value="">📚 Add to Collection</option>
|
<option value="">📚 Add to Collection</option>
|
||||||
{collections.map(collection => (
|
{collections.map(collection => (
|
||||||
|
|
@ -681,6 +691,7 @@ export default function Scanner() {
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="px-4 py-2 rounded-lg font-medium"
|
className="px-4 py-2 rounded-lg font-medium"
|
||||||
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
|
||||||
|
aria-label="Add selected cards to deck"
|
||||||
>
|
>
|
||||||
<option value="">🃏 Add to Deck</option>
|
<option value="">🃏 Add to Deck</option>
|
||||||
{decks.map(deck => (
|
{decks.map(deck => (
|
||||||
|
|
@ -700,9 +711,9 @@ export default function Scanner() {
|
||||||
onClick={() => setSelectedCards(new Set())}
|
onClick={() => setSelectedCards(new Set())}
|
||||||
className="px-3 py-2 rounded-lg hover:opacity-80"
|
className="px-3 py-2 rounded-lg hover:opacity-80"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
title="Clear Selection"
|
aria-label="Clear selection"
|
||||||
>
|
>
|
||||||
✕
|
<span aria-hidden="true">✕</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -714,11 +725,22 @@ export default function Scanner() {
|
||||||
{/* Create Collection Modal */}
|
{/* Create Collection Modal */}
|
||||||
{showCreateCollection && (
|
{showCreateCollection && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="rounded-xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
<div
|
||||||
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
ref={createCollectionDialogRef}
|
||||||
|
className="rounded-xl p-6 max-w-md w-full mx-4"
|
||||||
|
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="create-collection-title"
|
||||||
|
>
|
||||||
|
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
Create New Collection
|
Create New Collection
|
||||||
</h3>
|
</h3>
|
||||||
|
<label htmlFor="create-collection-name" className="sr-only">
|
||||||
|
Collection name
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="create-collection-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Collection name..."
|
placeholder="Collection name..."
|
||||||
value={newCollectionName}
|
value={newCollectionName}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue