a11y: add screen reader support (#2040)
Tested with VoiceOver on iOS and macOS
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, isValidElement, ReactElement, ReactNode } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import React, { useState, isValidElement, ReactElement, ReactNode, useRef } from 'react';
|
||||
import { Overlay } from './Overlay';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
interface DropdownProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
menuClassName?: string;
|
||||
buttonClassName?: string;
|
||||
@@ -52,6 +52,7 @@ const enhanceMenuItems = (
|
||||
};
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
label,
|
||||
className,
|
||||
menuClassName,
|
||||
buttonClassName,
|
||||
@@ -59,14 +60,45 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
children,
|
||||
onToggle,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
console.error('Toggling dropdown, current state:', isOpen);
|
||||
const newIsOpen = !isOpen;
|
||||
setIsDropdownOpen(newIsOpen);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!isOpen) setIsDropdownOpen(true);
|
||||
e.stopPropagation();
|
||||
} else if (e.key === 'Escape' && isOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (!isOpen && !isPointerDown) {
|
||||
setIsDropdownOpen(true);
|
||||
}
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const relatedTarget = e.relatedTarget;
|
||||
if (relatedTarget && !containerRef.current.contains(relatedTarget)) {
|
||||
console.log('Dropdown lost focus, closing menu');
|
||||
setIsFocused(false);
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setIsDropdownOpen = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
onToggle?.(isOpen);
|
||||
@@ -86,28 +118,28 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
<div className='dropdown-container flex'>
|
||||
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
|
||||
<div
|
||||
ref={containerRef}
|
||||
role='menu'
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
aria-label={_('Menu')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (!isOpen) toggleDropdown();
|
||||
} else if (e.key === 'Escape' && isOpen) {
|
||||
toggleDropdown();
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className={clsx('dropdown', className)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={() => setIsPointerDown(true)}
|
||||
onPointerUp={() => setIsPointerDown(false)}
|
||||
className={clsx('dropdown flex flex-col', className)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
role='none'
|
||||
className={clsx('dropdown-toggle', buttonClassName, isFocused && 'bg-base-300/50')}
|
||||
onClick={toggleDropdown}
|
||||
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-300/50')}
|
||||
>
|
||||
{toggleButton}
|
||||
</div>
|
||||
{isOpen && childrenWithToggle}
|
||||
<div role='none' className={clsx('flex items-center justify-center', !isOpen && 'hidden')}>
|
||||
{isOpen && childrenWithToggle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user