feat: enhance accessibility by adding ARIA attributes and focus styles; improve canvas and button elements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Check, CaretDown } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type BottomControlSelectOption<TValue extends string> = {
|
||||
@@ -20,6 +20,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const listboxId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0];
|
||||
@@ -50,8 +51,11 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
@@ -66,6 +70,21 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
const openAndFocusOption = (index: number) => {
|
||||
setOpen(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||||
optionButtons?.[Math.max(0, Math.min(index, options.length - 1))]?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
|
||||
openAndFocusOption(event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : selectedIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
|
||||
<button
|
||||
@@ -75,6 +94,8 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
aria-label={props["aria-label"]}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
|
||||
@@ -86,6 +107,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
{open && placement === "inline" ? (
|
||||
<SelectOptions
|
||||
menuRef={menuRef}
|
||||
id={listboxId}
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
@@ -97,6 +119,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
createPortal(
|
||||
<SelectOptions
|
||||
menuRef={menuRef}
|
||||
id={listboxId}
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
@@ -114,6 +137,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
|
||||
|
||||
type SelectOptionsProps<TValue extends string> = {
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
id: string;
|
||||
options: readonly BottomControlSelectOption<TValue>[];
|
||||
value: TValue;
|
||||
className: string;
|
||||
@@ -123,10 +147,28 @@ type SelectOptionsProps<TValue extends string> = {
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function SelectOptions<TValue extends string>({ menuRef, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
|
||||
function SelectOptions<TValue extends string>({ menuRef, id, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
|
||||
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||||
if (!optionButtons) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const nextIndex = event.key === "ArrowDown" ? (index + 1) % options.length
|
||||
: event.key === "ArrowUp" ? (index - 1 + options.length) % options.length
|
||||
: event.key === "Home" ? 0
|
||||
: event.key === "End" ? options.length - 1
|
||||
: undefined;
|
||||
if (nextIndex === undefined) return;
|
||||
event.preventDefault();
|
||||
optionButtons[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
||||
{options.map((option) => {
|
||||
<div ref={menuRef} id={id} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
@@ -135,6 +177,8 @@ function SelectOptions<TValue extends string>({ menuRef, options, value, classNa
|
||||
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onKeyDown={(event) => handleOptionKeyDown(event, index)}
|
||||
onClick={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
|
||||
Reference in New Issue
Block a user