forked from akai/readest
a11y: add screen reader support (#2040)
Tested with VoiceOver on iOS and macOS
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' />}
|
||||
|
||||
Reference in New Issue
Block a user