From 913abdd353cd188ed89c0662bb55e524f30c8857 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Wed, 18 Feb 2026 20:32:20 +0100 Subject: [PATCH] code-review-fix task 11.3: add keyboard navigation and click-outside close to ChartSelector --- src/components/ChartSelector.tsx | 115 +++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/src/components/ChartSelector.tsx b/src/components/ChartSelector.tsx index e2eb9f3..2850dbd 100644 --- a/src/components/ChartSelector.tsx +++ b/src/components/ChartSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef, useEffect, KeyboardEvent } from 'react'; import { ChevronDown, Trash2 } from 'lucide-react'; interface Chart { @@ -24,9 +24,90 @@ export default function ChartSelector({ }: ChartSelectorProps) { const [isOpen, setIsOpen] = useState(false); const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const containerRef = useRef(null); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const activeChart = charts.find((c) => c.id === activeChartId); + // Click-outside handler + useEffect(() => { + function handleMouseDown(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setFocusedIndex(-1); + } + } + + document.addEventListener('mousedown', handleMouseDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + }; + }, []); + + // Scroll focused item into view + useEffect(() => { + if (isOpen && focusedIndex >= 0 && itemRefs.current[focusedIndex]) { + itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex, isOpen]); + + // Reset focused index when dropdown closes + useEffect(() => { + if (!isOpen) { + setFocusedIndex(-1); + } + }, [isOpen]); + + function handleToggleKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setIsOpen((prev) => !prev); + if (!isOpen) { + setFocusedIndex(0); + } + } else if (event.key === 'Escape') { + setIsOpen(false); + setFocusedIndex(-1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + setFocusedIndex(0); + } else { + setFocusedIndex((prev) => Math.min(prev + 1, charts.length - 1)); + } + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (isOpen) { + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } + } + } + + function handleListKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, charts.length - 1)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < charts.length) { + onSelectChart(charts[focusedIndex].id); + setIsOpen(false); + } + } else if (event.key === 'Escape') { + setIsOpen(false); + setFocusedIndex(-1); + } + } + if (charts.length === 0) { return (

@@ -36,9 +117,17 @@ export default function ChartSelector({ } return ( -

+
{isOpen && ( -
- {charts.map((chart) => ( +
+ {charts.map((chart, index) => (
{ itemRefs.current[index] = el; }} + role="option" + aria-selected={chart.id === activeChartId} + tabIndex={focusedIndex === index ? 0 : -1} + className={`flex items-center justify-between px-2 py-1.5 text-xs hover:bg-accent cursor-pointer outline-none ${ chart.id === activeChartId ? 'bg-accent/50' : '' - }`} + } ${focusedIndex === index ? 'ring-1 ring-inset ring-ring' : ''}`} + onMouseEnter={() => setFocusedIndex(index)} >