refactor(a11y): add basic lint for accessibility (#2021)

This commit is contained in:
Huang Xin
2025-09-12 00:57:04 +08:00
committed by GitHub
parent 2571ceede4
commit 9fd152d727
56 changed files with 511 additions and 314 deletions
+60 -56
View File
@@ -85,67 +85,71 @@ export const AboutWindow = () => {
onClose={handleClose}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center gap-2 px-8'>
<div className='mb-2 mt-8'>
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
{isOpen && (
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center gap-2 px-8'>
<div className='mb-2 mt-8'>
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: getAppVersion() })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{!updateStatus && (
<button
className='badge badge-primary cursor-pointer p-2'
onClick={appService?.hasUpdater ? handleCheckUpdate : handleShowRecentUpdates}
>
{_('Check Update')}
</button>
)}
{updateStatus === 'updated' && (
<p className='text-neutral-content mt-2 text-xs'>
{_('Already the latest version')}
</p>
)}
{updateStatus === 'checking' && (
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
)}
{updateStatus === 'error' && (
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
)}
</div>
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: getAppVersion() })} {`(${browserInfo})`}
<div className='divider py-16 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
</div>
<div className='h-5'>
{!updateStatus && (
<span
className='badge badge-primary cursor-pointer p-2'
onClick={appService?.hasUpdater ? handleCheckUpdate : handleShowRecentUpdates}
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<Link
href='https://www.gnu.org/licenses/agpl-3.0.html'
className='text-blue-500 underline'
>
{_('Check Update')}
</span>
)}
{updateStatus === 'updated' && (
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
)}
{updateStatus === 'checking' && (
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
)}
{updateStatus === 'error' && (
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
)}
GNU Affero General Public License v3.0
</Link>
. You are free to use, modify, and distribute this software under the terms of the
AGPL v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
GitHub
</Link>
.
</p>
<LegalLinks />
</div>
</div>
<div className='divider py-16 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<Link
href='https://www.gnu.org/licenses/agpl-3.0.html'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</Link>
. You are free to use, modify, and distribute this software under the terms of the AGPL
v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
GitHub
</Link>
.
</p>
<LegalLinks />
</div>
</div>
)}
</Dialog>
);
};
+8 -4
View File
@@ -9,6 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getDirFromUILanguage } from '@/utils/rtl';
import { eventDispatcher } from '@/utils/event';
import { Overlay } from './Overlay';
const VELOCITY_THRESHOLD = 0.5;
const SNAP_THRESHOLD = 0.2;
@@ -146,7 +147,9 @@ const Dialog: React.FC<DialogProps> = ({
}
};
const { handleDragStart } = useDrag(handleDragMove, handleDragEnd);
const handleDragKeyDown = () => {};
const { handleDragStart } = useDrag(handleDragMove, handleDragKeyDown, handleDragEnd);
return (
<dialog
@@ -159,13 +162,13 @@ const Dialog: React.FC<DialogProps> = ({
)}
dir={isRtl ? 'rtl' : undefined}
>
<div
<Overlay
className={clsx(
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
'z-10 bg-black/50 sm:bg-black/50',
appService?.hasRoundedWindow && 'rounded-window',
bgClassName,
)}
onClick={onClose}
onDismiss={onClose}
/>
<div
className={clsx(
@@ -184,6 +187,7 @@ const Dialog: React.FC<DialogProps> = ({
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
}}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={clsx(
'drag-handle h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
+21 -7
View File
@@ -1,5 +1,7 @@
import clsx from 'clsx';
import React, { useState, isValidElement, ReactElement, ReactNode } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { Overlay } from './Overlay';
import MenuItem from './MenuItem';
interface DropdownProps {
@@ -57,12 +59,12 @@ const Dropdown: React.FC<DropdownProps> = ({
children,
onToggle,
}) => {
const _ = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const toggleDropdown = () => {
const newIsOpen = !isOpen;
setIsOpen(newIsOpen);
onToggle?.(newIsOpen);
setIsDropdownOpen(newIsOpen);
};
const setIsDropdownOpen = (isOpen: boolean) => {
@@ -82,12 +84,24 @@ const Dropdown: React.FC<DropdownProps> = ({
return (
<div className='dropdown-container flex'>
{isOpen && (
<div className='fixed inset-0 bg-transparent' onClick={() => setIsDropdownOpen(false)} />
)}
<div className={clsx('dropdown', className)}>
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
<div
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)}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
tabIndex={-1}
onClick={toggleDropdown}
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-300/50')}
>
@@ -37,6 +37,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
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',
@@ -0,0 +1,32 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
interface OverlayProps {
onDismiss: () => void;
dismissLabel?: string;
className?: string;
}
export const Overlay: React.FC<OverlayProps> = ({ onDismiss, dismissLabel, className }) => {
const _ = useTranslation();
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onDismiss();
}
};
return (
<div
className={clsx('overlay fixed inset-0 cursor-default', className)}
role='button'
tabIndex={-1}
aria-label={dismissLabel || _('Dismiss')}
onClick={onDismiss}
onContextMenu={onDismiss}
onKeyDown={handleKeyDown}
/>
);
};
+17 -1
View File
@@ -6,9 +6,11 @@ import { AuthProvider } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { initSystemThemeListener } from '@/store/themeStore';
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { getLocale } from '@/utils/misc';
import i18n from '@/i18n/i18n';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
@@ -16,6 +18,20 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
useSafeAreaInsets(); // Initialize safe area insets
useEffect(() => {
const handlerLanguageChanged = (lng: string) => {
document.documentElement.lang = lng;
};
const locale = getLocale();
handlerLanguageChanged(locale);
i18n.on('languageChanged', handlerLanguageChanged);
return () => {
i18n.off('languageChanged', handlerLanguageChanged);
};
}, []);
useEffect(() => {
loadDataTheme();
if (appService) {
initSystemThemeListener(appService);
}
+3 -3
View File
@@ -47,9 +47,9 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelCla
labelClassName,
)}
>
<div className='lg:tooltip lg:tooltip-right' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
<span className='truncate' title={quota.tooltip}>
{quota.name}
</span>
<div className='text-right text-sm'>
{quota.used} / {quota.total} {quota.unit}
</div>
+2 -5
View File
@@ -25,11 +25,8 @@ export default function Select({
<select
value={value}
onChange={onChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-sm',
'bg-base-200 focus:outline-none focus:ring-0',
className,
)}
onKeyDown={(e) => e.stopPropagation()}
className={clsx('select bg-base-200 h-8 min-h-8 rounded-md border-none text-sm', className)}
disabled={disabled}
style={{
textAlignLast: 'end',
@@ -171,7 +171,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
<div className='bg-base-100 relative w-full rounded-lg'>
<div className='mb-6 flex items-start gap-4'>
<div className='flex w-[30%] max-w-32 flex-col gap-2'>
<div
<button
className='aspect-[28/41] h-full shadow-md'
onClick={!isCoverLocked ? handleSelectLocalImage : undefined}
>
@@ -186,7 +186,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
...(newCoverImageUrl ? { coverImageUrl: newCoverImageUrl } : {}),
}}
/>
</div>
</button>
<div className='flex w-full gap-1'>
<button
onClick={handleSelectLocalImage}
@@ -232,12 +232,14 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
</Dialog>
{/* Source Selection Modal */}
<SourceSelector
sources={availableSources}
isOpen={showSourceSelection}
onSelect={handleSourceSelection}
onClose={handleCloseSourceSelection}
/>
{showSourceSelection && (
<SourceSelector
sources={availableSources}
isOpen={showSourceSelection}
onSelect={handleSourceSelection}
onClose={handleCloseSourceSelection}
/>
)}
{activeDeleteAction && currentDeleteConfig && (
<div
@@ -75,10 +75,9 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
toggleButton={<MdOutlineDelete className='fill-red-500' />}
>
<div
tabIndex={0}
className={clsx(
'delete-menu dropdown-content dropdown-center no-triangle',
'border-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
)}
>
<MenuItem
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { MdOutlineCheck, MdOutlineEdit } from 'react-icons/md';
import { BookMetadata } from '@/libs/document';
@@ -24,6 +24,13 @@ interface SourceSelectorProps {
const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSelect, onClose }) => {
const _ = useTranslation();
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && modalRef.current) {
modalRef.current.focus();
}
}, [isOpen]);
const getConfidenceIcon = (confidence: number) => {
if (confidence >= 90) return <MdOutlineCheck className='text-green-500' />;
@@ -34,16 +41,23 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSele
if (!isOpen) return null;
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='bg-base-100 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg p-6'>
<div className='source-selector fixed inset-0 z-[60] flex items-center justify-center bg-black/50'>
<div
ref={modalRef}
tabIndex={-1}
role='dialog'
aria-modal='true'
className='bg-base-100 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-lg p-6'
>
<h3 className='mb-4 text-lg font-semibold'>{_('Select Metadata Source')}</h3>
<div className='space-y-3'>
{sources.map((source, index) => (
<div
<button
tabIndex={0}
key={index}
onClick={() => onSelect(source)}
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
className='hover:bg-base-300/75 bg-base-200 border-base-200 cursor-pointer rounded-md border p-3 transition-colors'
>
<div className='flex items-start gap-4'>
<div className='aspect-[28/41] h-full w-[40%] max-w-32 shadow-md'>
@@ -86,18 +100,19 @@ const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, isOpen, onSele
</div>
</div>
</div>
</div>
</button>
))}
<div
<button
tabIndex={0}
onClick={onClose}
className='hover:bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
className='hover:bg-base-300/75 border-base-200 bg-base-200 cursor-pointer rounded-md border p-3 transition-colors'
>
<div className='flex items-center gap-2'>
<MdOutlineEdit className='h-4 w-4' />
<span className='font-medium'>{_('Keep manual input')}</span>
</div>
</div>
</button>
</div>
<div className='mt-6 flex justify-end gap-2'>