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:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { feedbackApi } from '@/api'
|
import { feedbackApi } from '@/api'
|
||||||
@@ -26,6 +26,9 @@ export function FeedbackPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
const [typeDropdownOpen, setTypeDropdownOpen] = 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
|
const canSubmit = email.trim() && feedbackType && message.trim().length >= 10
|
||||||
|
|
||||||
@@ -34,8 +37,70 @@ export function FeedbackPage() {
|
|||||||
const handleSelectType = (value: string) => {
|
const handleSelectType = (value: string) => {
|
||||||
setFeedbackType(value)
|
setFeedbackType(value)
|
||||||
setTypeDropdownOpen(false)
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!canSubmit || isSubmitting) return
|
if (!canSubmit || isSubmitting) return
|
||||||
@@ -119,8 +184,15 @@ export function FeedbackPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
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)}
|
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||||
|
onKeyDown={handleDropdownKeyDown}
|
||||||
className={cn(
|
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",
|
"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"
|
feedbackType ? "text-foreground" : "text-muted-foreground"
|
||||||
@@ -130,15 +202,25 @@ export function FeedbackPage() {
|
|||||||
<ChevronDown size={16} className={cn("transition-transform", typeDropdownOpen && "rotate-180")} />
|
<ChevronDown size={16} className={cn("transition-transform", typeDropdownOpen && "rotate-180")} />
|
||||||
</button>
|
</button>
|
||||||
{typeDropdownOpen && (
|
{typeDropdownOpen && (
|
||||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
<div
|
||||||
{FEEDBACK_TYPES.map(type => (
|
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
|
<button
|
||||||
key={type.value}
|
key={type.value}
|
||||||
|
id={`feedback-type-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={feedbackType === type.value}
|
||||||
onClick={() => handleSelectType(type.value)}
|
onClick={() => handleSelectType(type.value)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-3 py-2.5 hover:bg-accent transition-colors",
|
"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>
|
<div className="text-sm font-medium text-foreground">{type.value}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user