fix: add keyboard navigation to feedback type dropdown

Arrow keys open/navigate options, Enter/Space selects, Escape closes.
Added ARIA attributes (combobox, listbox, option) and click-outside
dismiss. Mouse hover also updates the highlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-18 18:43:42 -05:00
parent cd19005831
commit d2b5a8017e

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { feedbackApi } from '@/api'
@@ -26,6 +26,9 @@ export function FeedbackPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const triggerRef = useRef<HTMLButtonElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
const canSubmit = email.trim() && feedbackType && message.trim().length >= 10
@@ -34,8 +37,70 @@ export function FeedbackPage() {
const handleSelectType = (value: string) => {
setFeedbackType(value)
setTypeDropdownOpen(false)
setHighlightedIndex(-1)
triggerRef.current?.focus()
}
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (!typeDropdownOpen) {
setTypeDropdownOpen(true)
setHighlightedIndex(0)
} else {
setHighlightedIndex(i => (i + 1) % FEEDBACK_TYPES.length)
}
break
case 'ArrowUp':
e.preventDefault()
if (!typeDropdownOpen) {
setTypeDropdownOpen(true)
setHighlightedIndex(FEEDBACK_TYPES.length - 1)
} else {
setHighlightedIndex(i => (i - 1 + FEEDBACK_TYPES.length) % FEEDBACK_TYPES.length)
}
break
case 'Enter':
case ' ':
e.preventDefault()
if (typeDropdownOpen && highlightedIndex >= 0) {
handleSelectType(FEEDBACK_TYPES[highlightedIndex].value)
} else {
setTypeDropdownOpen(true)
setHighlightedIndex(0)
}
break
case 'Escape':
e.preventDefault()
setTypeDropdownOpen(false)
setHighlightedIndex(-1)
triggerRef.current?.focus()
break
case 'Tab':
if (typeDropdownOpen) {
setTypeDropdownOpen(false)
setHighlightedIndex(-1)
}
break
}
}
useEffect(() => {
if (!typeDropdownOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
listboxRef.current && !listboxRef.current.contains(e.target as Node)
) {
setTypeDropdownOpen(false)
setHighlightedIndex(-1)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [typeDropdownOpen])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit || isSubmitting) return
@@ -119,8 +184,15 @@ export function FeedbackPage() {
</label>
<div className="relative">
<button
ref={triggerRef}
type="button"
role="combobox"
aria-expanded={typeDropdownOpen}
aria-haspopup="listbox"
aria-controls="feedback-type-listbox"
aria-activedescendant={highlightedIndex >= 0 ? `feedback-type-${highlightedIndex}` : undefined}
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
onKeyDown={handleDropdownKeyDown}
className={cn(
"w-full rounded-lg border border-border bg-card px-3 py-2 text-left flex items-center justify-between focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none",
feedbackType ? "text-foreground" : "text-muted-foreground"
@@ -130,15 +202,25 @@ export function FeedbackPage() {
<ChevronDown size={16} className={cn("transition-transform", typeDropdownOpen && "rotate-180")} />
</button>
{typeDropdownOpen && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
{FEEDBACK_TYPES.map(type => (
<div
ref={listboxRef}
id="feedback-type-listbox"
role="listbox"
className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden"
>
{FEEDBACK_TYPES.map((type, index) => (
<button
key={type.value}
id={`feedback-type-${index}`}
type="button"
role="option"
aria-selected={feedbackType === type.value}
onClick={() => handleSelectType(type.value)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full text-left px-3 py-2.5 hover:bg-accent transition-colors",
feedbackType === type.value && "bg-accent"
feedbackType === type.value && "bg-accent",
highlightedIndex === index && feedbackType !== type.value && "bg-accent/50"
)}
>
<div className="text-sm font-medium text-foreground">{type.value}</div>