feat(ui): a11y + toggle + viewport-close for voice picker (#964)

- aria-haspopup/aria-expanded/aria-controls on trigger; role="listbox"
  + aria-label on dropdown; role="option" + aria-selected on rows. Listbox
  id from useId() so multiple pickers don't collide.
- Clicking the trigger while open now closes it (toggle), not a no-op.
- Close on window scroll/resize: the fixed-position dropdown doesn't
  reflow with the viewport, so pinning it in the wrong place is worse
  than closing.
This commit is contained in:
Viet Tran authored and GitHub committed 2026-04-19 14:29:20 +07:00
1 parent 597e59ac04
commit 7b83e890aa
1 file changed
+25 -5
+25 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useId, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCwIcon, ChevronDownIcon } from "lucide-react";
import { createPortal } from "react-dom";
@@ -43,6 +43,8 @@ function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boole
return (
<div
role="option"
aria-selected={selected}
className={cn(
"flex items-center gap-2 rounded-sm px-2 py-1.5 cursor-pointer hover:bg-accent hover:text-accent-foreground",
selected && "bg-accent/60",
@@ -158,6 +160,7 @@ function DynamicVoicePicker({
const [search, setSearch] = useState("");
const triggerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const { data: voices = [], isLoading } = useVoices();
const { mutate: refresh, isPending: refreshing } = useRefreshVoices();
@@ -168,10 +171,13 @@ function DynamicVoicePicker({
? voices.filter((v) => v.name.toLowerCase().includes(search.toLowerCase()))
: voices;
const handleOpen = () => {
const handleToggle = () => {
if (disabled) return;
setOpen(true);
setSearch("");
setOpen((prev) => {
if (prev) return false;
setSearch("");
return true;
});
};
const handleSelect = (voice: Voice) => {
@@ -209,18 +215,29 @@ function DynamicVoicePicker({
}
};
// Close on scroll/resize — position:fixed dropdown does not reflow with
// the viewport, so leaving it open in the wrong place is worse than closing.
const handleReposition = () => setOpen(false);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
window.addEventListener("scroll", handleReposition, true);
window.addEventListener("resize", handleReposition);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("scroll", handleReposition, true);
window.removeEventListener("resize", handleReposition);
};
}, [open]);
const dropdownContent = open && (
<div
ref={dropdownRef}
id={listboxId}
role="listbox"
aria-label={t("voice_placeholder")}
className="pointer-events-auto z-50 min-w-[280px] rounded-md border bg-popover text-popover-foreground shadow-md"
style={(() => {
if (!triggerRef.current) return {};
@@ -284,7 +301,10 @@ function DynamicVoicePicker({
<button
type="button"
disabled={disabled}
onClick={handleOpen}
onClick={handleToggle}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
className={cn(
"border-input dark:bg-input/30 flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1",