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
@@ -7,6 +7,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import FoliateViewer from './FoliateViewer';
@@ -27,6 +28,7 @@ interface BooksGridProps {
}
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
@@ -75,6 +77,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
}}
role='main'
aria-label={_('Books Grid')}
>
{bookKeys.map((bookKey, index) => {
const bookData = getBookData(bookKey);
@@ -15,6 +15,8 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useTranslation } from '@/hooks/useTranslation';
import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
@@ -62,6 +64,7 @@ const FoliateViewer: React.FC<{
gridInsets: Insets;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, gridInsets, contentInsets: insets }) => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
@@ -81,6 +84,8 @@ const FoliateViewer: React.FC<{
const [loading, setLoading] = useState(false);
const docLoaded = useRef(false);
useAutoFocus<HTMLDivElement>({ ref: containerRef });
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
@@ -410,7 +415,10 @@ const FoliateViewer: React.FC<{
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
tabIndex={-1}
role='document'
aria-label={_('Book Content')}
className='foliate-viewer h-[100%] w-[100%] focus:outline-none'
{...mouseHandlers}
{...touchHandlers}
/>
@@ -173,7 +173,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
return (
<>
<div
role='button'
role='none'
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
@@ -185,6 +186,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
role='group'
aria-label={_('Footer Bar')}
className={clsx(
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
@@ -7,6 +7,7 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import WindowButtons from '@/components/WindowButtons';
@@ -37,6 +38,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onCloseBook,
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
@@ -105,7 +107,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
}}
>
<div
role='button'
role='none'
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx('absolute top-0 z-10 h-11 w-full')}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
@@ -124,6 +127,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
/>
<div
ref={headerRef}
role='group'
aria-label={_('Header Bar')}
className={clsx(
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
`shadow-xs transition-[opacity,margin-top] duration-300`,
@@ -154,25 +159,29 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<div
role='contentinfo'
aria-label={_('Title') + ' - ' + bookTitle}
className={clsx(
'header-title z-15 bg-base-100 pointer-events-none hidden flex-1 items-center justify-center sm:flex',
!windowButtonVisible && 'absolute inset-0',
)}
>
<h2
<div
aria-hidden='true'
className={clsx(
'line-clamp-1 text-center text-xs font-semibold',
!windowButtonVisible && 'max-w-[50%]',
)}
>
{bookTitle}
</h2>
</div>
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4 ps-2'>
<SettingsToggler />
<NotebookToggler bookKey={bookKey} />
<Dropdown
label={_('View Options')}
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={iconSize16} />}
@@ -215,126 +215,128 @@ export const KOSyncSettingsWindow: React.FC = () => {
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.kosync.username,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.kosync.enabled}
onChange={() => handleDisconnect()}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.kosync.strategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name'), disabled: true },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Device Name')}</span>
</label>
<input
type='text'
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Server URL')}</span>
</label>
<input
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<form className='flex flex-col gap-4'>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.kosync.username,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.kosync.enabled}
onChange={() => handleDisconnect()}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.kosync.strategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name'), disabled: true },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Device Name')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
<span className='label-text font-medium'>{_('Server URL')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
</form>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !url || !username || !password}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</>
)}
</div>
<form className='flex flex-col gap-4'>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
/>
</div>
</form>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !url || !username || !password}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</>
)}
</div>
)}
</Dialog>
);
};
@@ -65,6 +65,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
role='group'
aria-label={_('Progress Information')}
style={
isVertical
? {
@@ -51,6 +51,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
role='contentinfo'
style={
isVertical
? {
@@ -68,7 +69,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}
}
>
<h2
<span
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
isVertical ? '' : 'line-clamp-1',
@@ -76,7 +77,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
)}
>
{section || ''}
</h2>
</span>
</div>
</>
);
@@ -211,6 +211,8 @@ const Notebook: React.FC = ({}) => {
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
role='group'
aria-label={_('Notebook')}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${notebookWidth}`,
@@ -212,6 +212,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
label={_('Settings Menu')}
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
@@ -5,6 +5,7 @@ import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import Dropdown from '@/components/Dropdown';
@@ -18,6 +19,7 @@ const SidebarHeader: React.FC<{
onTogglePin: () => void;
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const _ = useTranslation();
const { appService } = useEnv();
const {
isTrafficLightVisible,
@@ -75,6 +77,7 @@ const SidebarHeader: React.FC<{
<FiSearch size={iconSize18} className='text-base-content' />
</button>
<Dropdown
label={_('Book Menu')}
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
@@ -206,6 +206,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<Dropdown
label={_('Search Options')}
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
@@ -216,6 +216,8 @@ const SideBar: React.FC<{
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
isSideBarPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
role='group'
aria-label={_('Sidebar')}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${sideBarWidth}`,