a11y: add screen reader support (#2040)

Tested with VoiceOver on iOS and macOS
This commit is contained in:
Huang Xin
2025-09-15 04:00:42 +08:00
committed by GitHub
parent b1fb477359
commit bb34b1ba59
55 changed files with 870 additions and 344 deletions
+12 -28
View File
@@ -11,39 +11,23 @@ interface ButtonProps {
className?: string;
}
const Button: React.FC<ButtonProps> = ({
icon,
onClick,
disabled = false,
tooltip,
tooltipDirection = 'top',
className,
}) => {
const Button: React.FC<ButtonProps> = ({ icon, onClick, disabled = false, tooltip, className }) => {
const { appService } = useEnv();
return (
<div
<button
className={clsx(
'lg:tooltip z-50 h-8 min-h-8 w-8',
tooltip && `lg:tooltip-${tooltipDirection}`,
{
'tooltip-hidden': !tooltip,
},
'btn btn-ghost h-8 min-h-8 w-8 p-0',
appService?.isMobileApp && 'hover:bg-transparent',
disabled && 'btn-disabled !bg-transparent opacity-50',
className,
)}
data-tip={tooltip}
title={tooltip}
aria-label={tooltip}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
<button
className={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
appService?.isMobileApp && 'hover:bg-transparent',
disabled && 'btn-disabled !bg-transparent opacity-50',
className,
)}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
{icon}
</button>
</div>
{icon}
</button>
);
};
+24 -3
View File
@@ -4,6 +4,7 @@ import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
@@ -41,12 +42,14 @@ const Dialog: React.FC<DialogProps> = ({
contentClassName,
onClose,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight, safeAreaInsets } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const dialogRef = useRef<HTMLDialogElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
@@ -63,14 +66,30 @@ const Dialog: React.FC<DialogProps> = ({
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen) {
if (previousActiveElementRef.current) {
previousActiveElementRef.current.focus();
previousActiveElementRef.current = null;
}
return;
}
previousActiveElementRef.current = document.activeElement as HTMLElement;
setIsFullHeightInMobile(!snapHeight && isMobile);
window.addEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
const timer = setTimeout(() => {
if (dialogRef.current) {
dialogRef.current.focus();
}
}, 100);
return () => {
clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
@@ -155,7 +174,9 @@ const Dialog: React.FC<DialogProps> = ({
<dialog
ref={dialogRef}
id={id ?? 'dialog'}
tabIndex={-1}
open={isOpen}
aria-hidden={!isOpen}
className={clsx(
'modal sm:min-w-90 z-50 h-full w-full !items-start !bg-transparent sm:w-full sm:!items-center',
className,
@@ -204,7 +225,7 @@ const Dialog: React.FC<DialogProps> = ({
) : (
<div className='flex h-11 w-full items-center justify-between'>
<button
tabIndex={-1}
aria-label={_('Close')}
onClick={onClose}
className={
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
@@ -220,7 +241,7 @@ const Dialog: React.FC<DialogProps> = ({
<span className='line-clamp-1 text-center font-bold'>{title ?? ''}</span>
</div>
<button
tabIndex={-1}
aria-label={_('Close')}
onClick={onClose}
className={
'bg-base-300/65 btn btn-ghost btn-circle ml-auto hidden h-6 min-h-6 w-6 focus:outline-none sm:flex'
+50 -18
View File
@@ -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>
);
+31
View File
@@ -0,0 +1,31 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
interface MenuProps {
children: React.ReactNode;
label: string;
className?: string;
}
const Menu: React.FC<MenuProps> = ({ children, className }) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setTimeout(() => {
if (menuRef.current) {
const firstItem = menuRef.current.querySelector('[role="menuitem"]');
if (firstItem) {
(firstItem as HTMLElement).focus();
}
}
}, 200);
}, []);
return (
<div ref={menuRef} role='none' className={clsx(className)}>
{children}
</div>
);
};
export default Menu;
+53 -27
View File
@@ -1,10 +1,13 @@
import clsx from 'clsx';
import React from 'react';
import { IconType } from 'react-icons';
import { MdCheck } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface MenuItemProps {
label: string;
toggled?: boolean;
description?: string;
tooltip?: string;
buttonClass?: string;
@@ -12,7 +15,7 @@ interface MenuItemProps {
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
transient?: boolean; // For transient items the dropdown will close on click
transient?: boolean;
Icon?: React.ReactNode | IconType;
children?: React.ReactNode;
onClick?: () => void;
@@ -21,6 +24,7 @@ interface MenuItemProps {
const MenuItem: React.FC<MenuItemProps> = ({
label,
toggled,
description,
tooltip,
buttonClass,
@@ -34,36 +38,30 @@ const MenuItem: React.FC<MenuItemProps> = ({
onClick,
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const iconSize = useResponsiveSize(16);
const menuButton = (
<button
tabIndex={0}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full flex-col items-center justify-center rounded-md p-1 py-[10px]',
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
aria-label={label}
data-tip={tooltip ? tooltip : ''}
onClick={() => {
onClick?.();
if (transient) {
setIsDropdownOpen?.(false);
}
}}
disabled={disabled}
>
const IconType = Icon || (toggled !== undefined ? (toggled ? MdCheck : undefined) : undefined);
const handleClick = () => {
onClick?.();
if (transient) {
setIsDropdownOpen?.(false);
}
};
const buttonContent = (
<>
<div className='flex w-full items-center justify-between'>
<div className='flex min-w-0 items-center'>
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon
{typeof IconType === 'function' ? (
<IconType
className={disabled ? 'text-gray-400' : 'text-base-content'}
size={iconSize}
/>
) : (
Icon
IconType
)}
</span>
)}
@@ -96,22 +94,50 @@ const MenuItem: React.FC<MenuItemProps> = ({
</span>
)}
</div>
</button>
</>
);
if (children) {
return (
<ul className='menu rounded-box m-0 p-0'>
<li>
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
<ul className='menu rounded-box m-0 p-0' role='menuitem' tabIndex={-1}>
<li aria-label={label}>
<details>
<summary className='hover:bg-base-300 p-0 pr-3'>{menuButton}</summary>
<summary
className={clsx(
'hover:bg-base-300 text-base-content cursor-pointer rounded-md p-1 py-[10px] pr-3',
disabled && 'btn-disabled cursor-not-allowed text-gray-400',
buttonClass,
)}
title={tooltip ? tooltip : ''}
>
{buttonContent}
</summary>
{children}
</details>
</li>
</ul>
);
}
return menuButton;
return (
<button
role={disabled ? 'none' : 'menuitem'}
aria-label={toggled !== undefined ? `${label} - ${toggled ? _('ON') : _('OFF')}` : undefined}
aria-live={toggled === undefined ? 'polite' : 'off'}
tabIndex={disabled ? -1 : 0}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full flex-col items-center justify-center rounded-md p-1 py-[10px]',
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
title={tooltip ? tooltip : ''}
onClick={handleClick}
disabled={disabled}
>
{buttonContent}
</button>
);
};
export default MenuItem;
+2 -6
View File
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
interface OverlayProps {
onDismiss: () => void;
@@ -8,9 +7,7 @@ interface OverlayProps {
className?: string;
}
export const Overlay: React.FC<OverlayProps> = ({ onDismiss, dismissLabel, className }) => {
const _ = useTranslation();
export const Overlay: React.FC<OverlayProps> = ({ onDismiss, className }) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -21,9 +18,8 @@ export const Overlay: React.FC<OverlayProps> = ({ onDismiss, dismissLabel, class
return (
<div
className={clsx('overlay fixed inset-0 cursor-default', className)}
role='button'
role='none'
tabIndex={-1}
aria-label={dismissLabel || _('Dismiss')}
onClick={onDismiss}
onContextMenu={onDismiss}
onKeyDown={handleKeyDown}
@@ -70,6 +70,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
)}
{onDelete && (
<Dropdown
label={_('Delete Book Options')}
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineDelete className='fill-red-500' />}