fix(a11y): fix accessibility in dropdown menus for TalkBack, closes #3035 (#3126)

This commit is contained in:
Huang Xin
2026-01-31 19:05:56 +01:00
committed by GitHub
parent 7b4fc91994
commit 570598520f
11 changed files with 79 additions and 64 deletions
@@ -39,7 +39,9 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx('dropdown-content bg-base-100 rounded-box z-[1] mt-3 p-2 shadow')}
className={clsx(
'dropdown-content dropdown-center bg-base-100 rounded-box z-[1] mt-3 p-2 shadow',
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
@@ -149,7 +149,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
label={_('Import Books')}
className={clsx(
'exclude-title-bar-mousedown dropdown-bottom dropdown-center flex h-6 cursor-pointer justify-center',
'exclude-title-bar-mousedown dropdown-bottom dropdown-center cursor-pointer',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex touch-target items-center justify-center !bg-transparent'
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
@@ -327,7 +327,7 @@ export const MigrateDataWindow = () => {
>
<div
className={clsx(
'folder-menu dropdown-content dropdown-center no-triangle',
'folder-menu dropdown-content no-triangle left-0',
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
)}
>
@@ -175,6 +175,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
: _('Enable Quick Action on Selection')
}
className='exclude-title-bar-mousedown dropdown-bottom'
menuClassName='dropdown-center'
buttonClassName={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
viewSettings?.annotationQuickAction && 'bg-base-300/50',
@@ -9,12 +9,14 @@ import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface QuickActionMenuProps {
menuClassName?: string;
selectedAction?: AnnotationToolType | null;
onActionSelect: (action: AnnotationToolType) => void;
setIsDropdownOpen?: (open: boolean) => void;
}
const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
menuClassName,
selectedAction,
onActionSelect,
setIsDropdownOpen,
@@ -48,6 +50,7 @@ const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
className={clsx(
'annotation-quick-action-menu dropdown-content z-20 mt-1 border',
'bgcolor-base-200 shadow-2xl',
menuClassName,
)}
style={{
maxWidth: `${window.innerWidth - 40}px`,
@@ -68,6 +68,7 @@ const SidebarHeader: React.FC<{
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
containerClassName='h-8'
toggleButton={<MdOutlineMenu className='fill-base-content' />}
>
<BookMenu />
+25 -43
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { useState, isValidElement, ReactElement, ReactNode, useRef } from 'react';
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react';
import { useDropdownContext } from '@/context/DropdownContext';
import { Overlay } from './Overlay';
import MenuItem from './MenuItem';
@@ -8,6 +9,7 @@ interface DropdownProps {
className?: string;
menuClassName?: string;
buttonClassName?: string;
containerClassName?: string;
toggleButton: React.ReactNode;
children: ReactElement<{
setIsDropdownOpen: (isOpen: boolean) => void;
@@ -62,34 +64,29 @@ const Dropdown: React.FC<DropdownProps> = ({
className,
menuClassName,
buttonClassName,
containerClassName,
toggleButton,
children,
disabled,
onToggle,
showTooltip = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const lastInteractionWasTapOrClick = useRef(false);
const dropdownId = useId();
const context = useDropdownContext();
const isOpen = context ? context.openDropdownId === dropdownId : false;
const containerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const setIsDropdownOpen = (open: boolean) => {
if (disabled) return;
setIsOpen(open);
onToggle?.(open);
};
const handleTouchOrClick = () => {
lastInteractionWasTapOrClick.current = true;
setTimeout(() => (lastInteractionWasTapOrClick.current = false), 200);
};
const handleFocus = () => {
setIsFocused(true);
// skip touch and pointer triggered focus, this is only for keyboard and aria navigation
if (!lastInteractionWasTapOrClick.current) {
setIsDropdownOpen(true);
if (context) {
if (open) {
context.openDropdown(dropdownId);
} else {
context.closeDropdown(dropdownId);
}
}
onToggle?.(open);
};
const toggleDropdown = () => {
@@ -97,17 +94,6 @@ const Dropdown: React.FC<DropdownProps> = ({
setIsDropdownOpen(!isOpen);
};
const handleBlur = (e: React.FocusEvent) => {
if (process.env.NODE_ENV === 'development') {
return;
}
if (!containerRef.current) return;
if (!containerRef.current.contains(e.relatedTarget as Node)) {
setIsFocused(false);
setIsDropdownOpen(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!isOpen) setIsDropdownOpen(true);
@@ -129,16 +115,9 @@ const Dropdown: React.FC<DropdownProps> = ({
: children;
return (
<div className='dropdown-container flex'>
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
<div
ref={containerRef}
role='menu'
tabIndex={-1}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={clsx('dropdown flex flex-col', className)}
>
<div className='relative'>
<button
aria-haspopup='menu'
aria-expanded={isOpen}
@@ -149,16 +128,19 @@ const Dropdown: React.FC<DropdownProps> = ({
isFocused && isOpen && 'bg-base-300/50',
buttonClassName,
)}
onTouchStart={handleTouchOrClick}
onPointerDown={handleTouchOrClick}
onFocus={handleFocus}
onClick={toggleDropdown}
onKeyDown={handleKeyDown}
>
{toggleButton}
</button>
<div role='none' className={clsx('flex items-center justify-center')}>
<details
open={isOpen}
role='none'
className={clsx('dropdown flex items-center justify-center', className)}
>
<summary aria-hidden='true' className='list-none' />
{isOpen && childrenWithToggle}
</div>
</details>
</div>
</div>
);
+1 -12
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
interface MenuProps {
@@ -14,17 +14,6 @@ const Menu: React.FC<MenuProps> = ({ children, className, style, onCancel }) =>
useKeyDownActions({ onCancel, elementRef: menuRef });
useEffect(() => {
setTimeout(() => {
if (menuRef.current) {
const firstItem = menuRef.current.querySelector('[role="menuitem"]');
if (firstItem) {
(firstItem as HTMLElement).focus();
}
}
}, 200);
}, []);
return (
<div
ref={menuRef}
@@ -16,6 +16,7 @@ import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getLocale } from '@/utils/misc';
import { getDirFromUILanguage } from '@/utils/rtl';
import { DropdownProvider } from '@/context/DropdownContext';
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
const Providers = ({ children }: { children: React.ReactNode }) => {
@@ -69,10 +70,12 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
<AuthProvider>
<IconContext.Provider value={{ size: `${iconSize}px` }}>
<SyncProvider>
<CommandPaletteProvider>
{children}
<CommandPalette />
</CommandPaletteProvider>
<DropdownProvider>
<CommandPaletteProvider>
{children}
<CommandPalette />
</CommandPaletteProvider>
</DropdownProvider>
</SyncProvider>
</IconContext.Provider>
</AuthProvider>
@@ -0,0 +1,35 @@
// DropdownContext.tsx
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface DropdownContextValue {
openDropdownId: string | null;
openDropdown: (id: string) => void;
closeDropdown: (id: string) => void;
closeAll: () => void;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
export const DropdownProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const openDropdown = useCallback((id: string) => {
setOpenDropdownId(id);
}, []);
const closeDropdown = useCallback((id: string) => {
setOpenDropdownId((current) => (current === id ? null : current));
}, []);
const closeAll = useCallback(() => {
setOpenDropdownId(null);
}, []);
return (
<DropdownContext.Provider value={{ openDropdownId, openDropdown, closeDropdown, closeAll }}>
{children}
</DropdownContext.Provider>
);
};
export const useDropdownContext = () => useContext(DropdownContext);
+1 -2
View File
@@ -203,8 +203,7 @@ foliate-fxl {
transform: translateX(0);
}
.dropdown-center::before,
.dropdown-center::after {
.dropdown-center {
left: 50%;
transform: translateX(-50%);
}