refactor(a11y): add basic lint for accessibility (#2021)
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

This commit is contained in:
Huang Xin 2025-09-12 00:57:04 +08:00 committed by GitHub
parent 2571ceede4
commit 9fd152d727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 511 additions and 314 deletions

View file

@ -72,6 +72,11 @@ jobs:
run: |
pnpm test -- --watch=false
- name: run lint
working-directory: apps/readest-app
run: |
pnpm lint
- name: build the web App
if: matrix.config.platform == 'web'
working-directory: apps/readest-app

View file

@ -1,13 +1,25 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import path from 'node:path';
import js from '@eslint/js';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import { fileURLToPath } from 'node:url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [...compat.extends("next/core-web-vitals", "next/typescript")];
export default [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
plugins: {
'jsx-a11y': jsxA11y,
},
rules: {
...jsxA11y.configs.recommended.rules,
},
},
];

View file

@ -125,6 +125,7 @@
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"i18next-scanner": "^4.6.0",
"jsdom": "^26.1.0",
"mkdirp": "^3.0.1",

View file

@ -41,20 +41,9 @@ export const viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<html lang='en'>
<head>
<title>{title}</title>
<script
dangerouslySetInnerHTML={{
__html: `
const themeMode = localStorage.getItem('themeMode');
const themeColor = localStorage.getItem('themeColor');
if (themeMode && themeColor) {
document.documentElement.setAttribute('data-theme', \`\${themeColor}-\${themeMode}\`);
}
`,
}}
/>
<meta
name='viewport'
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'

View file

@ -330,16 +330,15 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
))}
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<div
<button
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
'mx-0 my-4 aspect-[28/41] sm:mx-4',
)}
role='button'
onClick={handleImportBooks}
>
<PiPlus className='size-10' color='gray' />
</div>
</button>
)}
</div>
{loading && (

View file

@ -150,29 +150,26 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<div
<button
onClick={handleRemoveFromGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span>{_('Remove From Group')}</span>
</div>
</button>
)}
<div
<button
onClick={handleCreateGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderAdd size={iconSize} />
<span>{_('Create New Group')}</span>
</div>
</button>
</div>
{showInput && (
<div className='mt-4 flex items-center gap-2'>
<input
type='text'
autoFocus
ref={editorRef}
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}

View file

@ -104,7 +104,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysShowStatusBar(settings.alwaysShowStatusBar);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
@ -169,7 +168,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className={clsx(
'settings-menu dropdown-content no-triangle border-base-100',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
@ -191,10 +189,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
)
}
>
<ul>
<div onClick={handleUserProfile} className='cursor-pointer'>
<ul className='flex flex-col'>
<button onClick={handleUserProfile} className='w-full'>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
</div>
</button>
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>

View file

@ -92,10 +92,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
};
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
<div className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'>
{viewOptions.map((option) => (
<MenuItem
key={option.value}

View file

@ -45,6 +45,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import { removeTabIndex } from '@/utils/a11y';
import Spinner from '@/components/Spinner';
import KOSyncConflictResolver from './KOSyncResolver';
@ -160,6 +161,8 @@ const FoliateViewer: React.FC<{
applyImageStyle(detail.doc);
removeTabIndex(detail.doc);
// Inline scripts in tauri platforms are not executed by default
if (viewSettings.allowScript && isTauriAppPlatform()) {
evalInlineScripts(detail.doc);

View file

@ -173,17 +173,20 @@ const FooterBar: React.FC<FooterBarProps> = ({
return (
<>
<div
role='button'
tabIndex={0}
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
className={clsx(
'footer-bar shadow-xs bottom-0 z-30 flex w-full flex-col',
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',

View file

@ -11,6 +11,7 @@ import { mountAdditionalFonts } from '@/styles/fonts';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { isCJKLang } from '@/utils/lang';
import { Overlay } from '@/components/Overlay';
import Popup from '@/components/Popup';
interface FootnotePopupProps {
@ -226,13 +227,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
return (
<div>
{showPopup && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{showPopup && <Overlay onDismiss={handleDismissPopup} />}
<Popup
width={responsiveWidth}
height={responsiveHeight}

View file

@ -95,7 +95,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
}}
>
<div
role='button'
tabIndex={0}
className={clsx('absolute top-0 z-10 h-11 w-full')}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>

View file

@ -39,12 +39,12 @@ Z-Index Layering Guide:
Floats above the content but below global dialogs.
40 TTS Bar
Mini controls for TTS playback on top of the TTS Control.
30 Footbar (TTS Control)
Persistent bottom controls and the TTS icon/panel.
30 TTS Control
Persistent TTS icon/panel.
20 Menu / Sidebar / Notebook (Pinned)
Docked navigation or note views.
10 Headerbar / Ribbon
Top toolbar and ribbon elements.
10 Headerbar / Footbar / Ribbon
Top toolbar, bottom footbar and ribbon elements.
0 Base Content
Main reading area or background content.
*/

View file

@ -146,7 +146,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
return (
<div
tabIndex={0}
className={clsx(
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
'bgcolor-base-200 border-base-200 shadow-2xl',

View file

@ -109,7 +109,6 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
onSave={handleSaveNote}
onEscape={handleEscape}
placeholder={_('Add your notes here...')}
autoFocus={true}
spellCheck={false}
/>
</div>

View file

@ -9,12 +9,13 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useThemeStore } from '@/store/themeStore';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { DragKey, useDrag } from '@/hooks/useDrag';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { Overlay } from '@/components/Overlay';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@ -33,7 +34,8 @@ const Notebook: React.FC = ({}) => {
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } =
useNotebookStore();
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
@ -79,9 +81,7 @@ const Notebook: React.FC = ({}) => {
settings.globalReadSettings.isNotebookPinned = !isNotebookPinned;
};
const handleClickOverlay = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const handleClickOverlay = () => {
setNotebookVisible(false);
setNotebookNewAnnotation(null);
setNotebookEditAnnotation(null);
@ -138,7 +138,19 @@ const Notebook: React.FC = ({}) => {
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleDragStart } = useDrag(onDragMove);
const onDragKeyDown = (data: { key: DragKey; step: number }) => {
const currentWidth = parseFloat(getNotebookWidth()) / 100;
let newWidth = currentWidth;
if (data.key === 'ArrowLeft') {
newWidth = Math.max(MIN_NOTEBOOK_WIDTH, currentWidth + data.step);
} else if (data.key === 'ArrowRight') {
newWidth = Math.min(MAX_NOTEBOOK_WIDTH, currentWidth - data.step);
}
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleDragStart, handleDragKeyDown } = useDrag(onDragMove, onDragKeyDown);
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
@ -189,7 +201,7 @@ const Notebook: React.FC = ({}) => {
return isNotebookVisible ? (
<>
{!isNotebookPinned && (
<div className='overlay fixed inset-0 z-[45] bg-black/20' onClick={handleClickOverlay} />
<Overlay className='z-[45] bg-black/20' onDismiss={handleClickOverlay} />
)}
<div
className={clsx(
@ -216,9 +228,15 @@ const Notebook: React.FC = ({}) => {
}
`}</style>
<div
className='drag-bar absolute left-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2'
className='drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2'
role='slider'
tabIndex={0}
aria-label={_('Resize Notebook')}
aria-orientation='horizontal'
aria-valuenow={parseFloat(notebookWidth)}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
onKeyDown={handleDragKeyDown}
/>
<div className='flex-shrink-0'>
<NotebookHeader
@ -263,7 +281,13 @@ const Notebook: React.FC = ({}) => {
{filteredExcerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
<div
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
handleEditNote(item, true);
}
}}
className='collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
@ -283,9 +307,11 @@ const Notebook: React.FC = ({}) => {
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end' dir='ltr'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
<div
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
aria-label={_('Delete')}
>
{_('Delete')}
</div>

View file

@ -33,8 +33,8 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
<div className='mb-3'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div className='flex items-center'>
<div
className='border-base-200 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
<button
className='border-base-200/75 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
@ -44,7 +44,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
value={value}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
className='bg-base-100 text-base-content border-base-200 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
className='bg-base-100 text-base-content border-base-200/75 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
/>
</div>

View file

@ -200,8 +200,16 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThemes).map(({ name, label, colors, isCustomizale }) => (
<label
<button
key={name}
tabIndex={0}
onClick={() => setThemeColor(name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setThemeColor(name);
}
e.stopPropagation();
}}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
@ -213,6 +221,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}}
>
<input
aria-label={_(label)}
type='radio'
name='theme'
value={name}
@ -231,15 +240,15 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</label>
</button>
))}
<label
<button
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
onClick={() => setShowCustomThemeEditor(true)}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</label>
</button>
</div>
</div>

View file

@ -141,9 +141,9 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
<div className='breadcrumbs py-1'>
<ul>
<li>
<a className='font-semibold' onClick={onBack}>
<button className='font-semibold' onClick={onBack}>
{_('Font')}
</a>
</button>
</li>
<li className='font-medium'>{_('Custom Fonts')}</li>
</ul>
@ -168,7 +168,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
<div className='grid grid-cols-2 gap-4'>
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
<div
<button
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportFont}
>
@ -180,11 +180,11 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
{_('Import Font')}
</div>
</div>
</div>
</button>
</div>
{availableFamilies.map((family) => (
<div
<button
key={family.name}
className={clsx(
'card h-12 border shadow-sm',
@ -215,7 +215,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
</button>
)}
</div>
</div>
</button>
))}
</div>

View file

@ -42,7 +42,6 @@ const DialogMenu: React.FC<DialogMenuProps> = ({
return (
<div
tabIndex={0}
className={clsx(
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
'text-base sm:text-sm',

View file

@ -1,63 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { FiChevronDown } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
interface DropDownProps {
selected: { option: string; label: string };
options: { option: string; label: string }[];
onSelect: (option: string) => void;
disabled?: boolean;
className?: string;
listClassName?: string;
}
const DropDown: React.FC<DropDownProps> = ({
selected,
options,
onSelect,
className,
listClassName,
disabled = false,
}) => {
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
return (
<div className={clsx('dropdown dropdown-bottom', className)}>
<button
tabIndex={0}
className={clsx(
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
disabled && 'btn-disabled',
)}
onClick={(e) => e.currentTarget.focus()}
>
<span>{selected.label}</span>
<FiChevronDown size={iconSize16} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
listClassName,
)}
>
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected.option === option && <MdCheck className='text-base-content' />}
</span>
<span>{label || option}</span>
</div>
</li>
))}
</ul>
</div>
);
};
export default DropDown;

View file

@ -34,7 +34,19 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
const option = options[index]!;
return (
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
<li
role='option'
aria-selected={selected === option.option}
className='px-2'
key={option.option}
style={style}
onClick={() => onSelect(option.option)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(option.option);
}
}}
>
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
{selected === option.option && (
@ -115,6 +127,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
</div>
</button>
<ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] mt-4 shadow',
@ -145,6 +158,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
<span>{_('System Fonts')}</span>
</div>
<ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] shadow',

View file

@ -72,19 +72,27 @@ const NumberInput: React.FC<NumberInputProps> = ({
<input
type='text'
inputMode='decimal'
disabled={disabled}
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
className='input input-ghost settings-content text-base-content w-16 max-w-xs rounded border-0 bg-transparent py-1 pe-3 ps-1 text-right !outline-none'
className={clsx(
'input input-ghost settings-content text-base-content w-16 max-w-xs rounded border-0 bg-transparent py-1 pe-3 ps-1 text-right !outline-none',
disabled && 'input-disabled cursor-not-allowed disabled:bg-transparent',
)}
onFocus={(e) => e.target.select()}
/>
<button
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.stopPropagation()}
onClick={decrement}
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>
<FiMinus className='h-4 w-4' />
</button>
<button
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.stopPropagation()}
onClick={increment}
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
>

View file

@ -41,8 +41,9 @@ const BookCard = ({ book }: { book: Book }) => {
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
aria-label={_('More Info')}
onClick={showBookDetails}
>
<MdInfoOutline size={iconSize18} className='fill-base-content' onClick={showBookDetails} />
<MdInfoOutline size={iconSize18} className='fill-base-content' />
</button>
</div>
);

View file

@ -85,11 +85,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
return (
<div
tabIndex={0}
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
>
<MenuItem
@ -149,7 +146,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
/>
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
</div>
);

View file

@ -38,7 +38,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const progress = getProgress(bookKey);
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
const handleClickItem = (event: React.MouseEvent) => {
const handleClickItem = (event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, cfi });
@ -108,7 +108,6 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
onChange={(value) => (editorDraftRef.current = value)}
onSave={handleSaveBookmark}
onEscape={() => setInlineEditMode(false)}
autoFocus={true}
spellCheck={false}
/>
</div>
@ -124,14 +123,25 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
return (
<li
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role='button'
ref={viewRef}
className={clsx(
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
isCurrent
? 'bg-base-300/85 hover:bg-base-300 focus:bg-base-300'
: 'hover:bg-base-300/55 focus:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
tabIndex={0}
onClick={handleClickItem}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClickItem(e);
} else {
e.stopPropagation();
}
}}
>
<div
className={clsx('min-h-4 p-0 transition-all duration-300 ease-in-out')}
@ -175,17 +185,20 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</div>
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={clsx(
'max-h-0 overflow-hidden p-0',
'transition-[max-height] duration-300 ease-in-out',
'group-hover:max-h-8 group-hover:overflow-visible',
'group-focus-within:max-h-8 group-focus-within:overflow-visible',
)}
style={
{
'--bottom-override': 0,
} as React.CSSProperties
}
// This is needed to prevent the parent onClick from being triggered
onClick={(e) => e.stopPropagation()}
>
<div className='flex cursor-default items-center justify-between'>
@ -199,7 +212,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
<TextButton
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
variant='primary'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
>
{_('Edit')}
</TextButton>
@ -208,7 +221,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
<TextButton
onClick={deleteNote.bind(null, item)}
variant='danger'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
className='opacity-0 transition duration-300 ease-in-out group-focus-within:opacity-100 group-hover:opacity-100'
>
{_('Delete')}
</TextButton>

View file

@ -46,7 +46,6 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
return (
<div
tabIndex={0}
className={clsx(
'book-menu dropdown-content dropdown-center border-base-200 z-20 w-56 border shadow-2xl',
menuClassName,

View file

@ -23,12 +23,22 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
return (
<li
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role='button'
ref={viewRef}
className={clsx(
'my-2 cursor-pointer rounded-lg p-2 text-sm',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-base-100',
)}
tabIndex={0}
onClick={() => onSelectResult(cfi)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onSelectResult(cfi);
} else {
e.stopPropagation();
}
}}
>
<div className='line-clamp-3'>
<span className=''>{excerpt.pre}</span>

View file

@ -1,24 +1,26 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { DragKey, useDrag } from '@/hooks/useDrag';
import { useThemeStore } from '@/store/themeStore';
import { Overlay } from '@/components/Overlay';
import useShortcuts from '@/hooks/useShortcuts';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
import useSidebar from '../../hooks/useSidebar';
import SearchBar from './SearchBar';
import SearchResults from './SearchResults';
import useShortcuts from '@/hooks/useShortcuts';
const MIN_SIDEBAR_WIDTH = 0.05;
const MAX_SIDEBAR_WIDTH = 0.45;
@ -28,6 +30,7 @@ const VELOCITY_THRESHOLD = 0.5;
const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
@ -37,11 +40,13 @@ const SideBar: React.FC<{
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const sidebarHeight = useRef(1.0);
const isMobile = window.innerWidth < 640;
const {
sideBarWidth,
isSideBarPinned,
isSideBarVisible,
getSideBarWidth,
setSideBarVisible,
handleSideBarResize,
handleSideBarTogglePin,
@ -89,6 +94,7 @@ const SideBar: React.FC<{
const heightFraction = data.clientY / window.innerHeight;
const newTop = Math.max(0.0, Math.min(1, heightFraction));
sidebarHeight.current = newTop;
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
@ -135,11 +141,29 @@ const SideBar: React.FC<{
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const handleHorizontalDragKeyDown = (data: { key: DragKey; step: number }) => {
const currentWidth = parseFloat(getSideBarWidth()) / 100;
let newWidth = currentWidth;
if (data.key === 'ArrowLeft') {
newWidth = Math.max(MIN_SIDEBAR_WIDTH, currentWidth - data.step);
} else if (data.key === 'ArrowRight') {
newWidth = Math.min(MAX_SIDEBAR_WIDTH, currentWidth + data.step);
}
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const handleVerticalDragKeyDown = () => {};
const { handleDragStart: handleVerticalDragStart } = useDrag(
handleVerticalDragMove,
handleVerticalDragKeyDown,
handleVerticalDragEnd,
);
const { handleDragStart: handleHorizontalDragStart } = useDrag(handleHorizontalDragMove);
const { handleDragStart: handleHorizontalDragStart, handleDragKeyDown } = useDrag(
handleHorizontalDragMove,
handleHorizontalDragKeyDown,
);
const handleClickOverlay = () => {
setSideBarVisible(false);
@ -182,10 +206,7 @@ const SideBar: React.FC<{
return isSideBarVisible ? (
<>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-[45] bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
<Overlay className='z-[45] bg-black/50 sm:bg-black/20' onDismiss={handleClickOverlay} />
)}
<div
className={clsx(
@ -222,6 +243,11 @@ const SideBar: React.FC<{
<div className='flex-shrink-0'>
{isMobile && (
<div
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='vertical'
aria-valuenow={sidebarHeight.current}
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleVerticalDragStart}
onTouchStart={handleVerticalDragStart}
@ -265,11 +291,17 @@ const SideBar: React.FC<{
)}
<div
className={clsx(
'drag-bar absolute right-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2',
'drag-bar absolute -right-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='horizontal'
aria-valuenow={parseFloat(sideBarWidth)}
onMouseDown={handleHorizontalDragStart}
onTouchStart={handleHorizontalDragStart}
onKeyDown={handleDragKeyDown}
></div>
</div>
</>

View file

@ -49,7 +49,7 @@ const TOCItemView = React.memo<{
);
const handleClickItem = useCallback(
(event: React.MouseEvent) => {
(event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
onItemClick(item);
},
@ -58,9 +58,10 @@ const TOCItemView = React.memo<{
return (
<div
tabIndex={0}
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
onKeyDown={item.href ? (e) => e.key === 'Enter' && handleClickItem(e) : undefined}
aria-expanded={flatItem.isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
@ -76,8 +77,11 @@ const TOCItemView = React.memo<{
}}
>
{item.subitems && (
<div
<button
onClick={handleToggleExpand}
onKeyDown={(e) => {
e.stopPropagation();
}}
className='inline-block cursor-pointer'
style={{
padding: '12px',
@ -85,7 +89,7 @@ const TOCItemView = React.memo<{
}}
>
{createExpanderIcon(flatItem.isExpanded || false)}
</div>
</button>
)}
<div
className='ms-2 truncate text-ellipsis'

View file

@ -36,12 +36,22 @@ const TabNavigation: React.FC<{
{tabs.map((tab) => (
<div
key={tab}
className='lg:tooltip lg:tooltip-top z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
data-tip={
tabIndex={0}
role='button'
className='z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
onClick={() => onTabChange(tab)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTabChange(tab);
}
}}
title={tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')}
aria-label={
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
>
<div className={clsx('flex h-6 items-center')} onClick={() => onTabChange(tab)}>
<div className={clsx('m-0 flex h-6 items-center p-0')}>
{tab === 'toc' ? (
<TOCIcon className='mx-auto' />
) : tab === 'annotations' ? (

View file

@ -14,6 +14,7 @@ import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@ -464,13 +465,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
return (
<>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
{showIndicator && (
<div
ref={iconRef}

View file

@ -11,7 +11,7 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div
<button
className={clsx(
'relative h-full w-full rounded-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
@ -59,7 +59,7 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
))}
</div>
</div>
</div>
</button>
);
};

View file

@ -281,6 +281,7 @@ const TTSPanel = ({
)}
</button>
<ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
@ -288,6 +289,7 @@ const TTSPanel = ({
)}
>
{timeoutOptions.map((option, index) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li
key={`${index}-${option.value}`}
onClick={() => onSelectTimeout(bookKey, option.value)}
@ -317,6 +319,7 @@ const TTSPanel = ({
<RiVoiceAiFill size={iconSize32} />
</button>
<ul
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
@ -338,6 +341,7 @@ const TTSPanel = ({
</span>
</div>
{voiceGroup.voices.map((voice, voiceIndex) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}

View file

@ -8,6 +8,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
sideBarWidth,
isSideBarVisible,
isSideBarPinned,
getSideBarWidth,
setSideBarWidth,
setSideBarVisible,
setSideBarPin,
@ -37,6 +38,7 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
sideBarWidth,
isSideBarPinned,
isSideBarVisible,
getSideBarWidth,
handleSideBarResize,
handleSideBarTogglePin,
setSideBarVisible,

View file

@ -48,7 +48,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
</div>
</div>
<div className='mb-6 space-y-3' onClick={() => onSelectPlan(index)}>
<button className='mb-6 space-y-3' onClick={() => onSelectPlan(index)}>
{plan.features.map((feature, featureIndex) => (
<div key={featureIndex} className='flex flex-col'>
<div className='flex items-center gap-2'>
@ -62,9 +62,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
)}
</div>
))}
</div>
</button>
<div className='mb-6 rounded-lg bg-white/50 p-4' onClick={() => onSelectPlan(index)}>
<button className='mb-6 rounded-lg bg-white/50 p-4' onClick={() => onSelectPlan(index)}>
<h5 className='mb-3 font-semibold'>{_('Plan Limits')}</h5>
<div className='space-y-2'>
{Object.entries(plan.limits).map(([key, value]) => (
@ -74,7 +74,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
</div>
))}
</div>
</div>
</button>
<PlanActionButton
plan={plan}

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>
);
};

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',

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')}
>

View file

@ -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',

View file

@ -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}
/>
);
};

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);
}

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>

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',

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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'>

View file

@ -2,8 +2,8 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { EnvConfigType } from '../services/environment';
import env from '../services/environment';
import { AppService } from '@/types/system';
import env from '../services/environment';
interface EnvContextType {
envConfig: EnvConfigType;

View file

@ -1,7 +1,10 @@
import { useCallback, useRef } from 'react';
export type DragKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
export const useDrag = (
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => void,
onDragKeyDown: (data: { key: DragKey; step: number }) => void,
onDragEnd?: (data: {
velocity: number;
deltaT: number;
@ -31,6 +34,10 @@ export const useDrag = (
}
startTime.current = performance.now();
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'col-resize';
const handleMove = (event: MouseEvent | TouchEvent) => {
if (isDragging.current) {
let deltaX = 0;
@ -58,6 +65,11 @@ export const useDrag = (
const handleEnd = (event: MouseEvent | TouchEvent) => {
isDragging.current = false;
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
let deltaX = 0;
let deltaY = 0;
let clientX = 0;
@ -96,5 +108,24 @@ export const useDrag = (
[onDragMove, onDragEnd],
);
return { handleDragStart };
const handleDragKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = 0.02;
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
onDragKeyDown({ key: e.key, step });
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
},
[onDragKeyDown],
);
return { handleDragStart, handleDragKeyDown };
};

View file

@ -1,5 +1,5 @@
import { AppProps } from 'next/app';
import Head from 'next/head';
import { AppProps } from 'next/app';
import { EnvProvider } from '@/context/EnvContext';
import Providers from '@/components/Providers';

View file

@ -12,6 +12,7 @@ interface NotebookState {
getIsNotebookVisible: () => boolean;
toggleNotebook: () => void;
toggleNotebookPin: () => void;
getNotebookWidth: () => string;
setNotebookWidth: (width: string) => void;
setNotebookVisible: (visible: boolean) => void;
setNotebookPin: (pinned: boolean) => void;
@ -29,6 +30,7 @@ export const useNotebookStore = create<NotebookState>((set, get) => ({
notebookEditAnnotation: null,
notebookAnnotationDrafts: {},
getIsNotebookVisible: () => get().isNotebookVisible,
getNotebookWidth: () => get().notebookWidth,
setNotebookWidth: (width: string) => set({ notebookWidth: width }),
toggleNotebook: () => set((state) => ({ isNotebookVisible: !state.isNotebookVisible })),
toggleNotebookPin: () => set((state) => ({ isNotebookPinned: !state.isNotebookPinned })),

View file

@ -6,6 +6,7 @@ interface SidebarState {
isSideBarVisible: boolean;
isSideBarPinned: boolean;
getIsSideBarVisible: () => boolean;
getSideBarWidth: () => string;
setSideBarBookKey: (key: string) => void;
setSideBarWidth: (width: string) => void;
toggleSideBar: () => void;
@ -20,6 +21,7 @@ export const useSidebarStore = create<SidebarState>((set, get) => ({
isSideBarVisible: false,
isSideBarPinned: false,
getIsSideBarVisible: () => get().isSideBarVisible,
getSideBarWidth: () => get().sideBarWidth,
setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }),
setSideBarWidth: (width: string) => set({ sideBarWidth: width }),
toggleSideBar: () => set((state) => ({ isSideBarVisible: !state.isSideBarVisible })),

View file

@ -135,6 +135,16 @@ export const useThemeStore = create<ThemeState>((set, get) => {
};
});
export const loadDataTheme = () => {
if (typeof localStorage === 'undefined' || typeof document === 'undefined') return;
const themeMode = localStorage.getItem('themeMode');
const themeColor = localStorage.getItem('themeColor');
if (themeMode && themeColor) {
document.documentElement.setAttribute('data-theme', `${themeColor}-${themeMode}`);
}
};
export const initSystemThemeListener = (appService: AppService) => {
if (typeof window === 'undefined' || !appService) return;

View file

@ -0,0 +1,5 @@
export const removeTabIndex = (document: Document) => {
document.querySelectorAll('a').forEach((a) => {
a.setAttribute('tabindex', '-1');
});
};

75
pnpm-lock.yaml generated
View file

@ -282,6 +282,9 @@ importers:
eslint-config-next:
specifier: 15.0.3
version: 15.0.3(eslint@9.28.0(jiti@1.21.6))(typescript@5.7.2)
eslint-plugin-jsx-a11y:
specifier: ^6.10.2
version: 6.10.2(eslint@9.28.0(jiti@1.21.6))
i18next-scanner:
specifier: ^4.6.0
version: 4.6.0(typescript@5.7.2)
@ -11197,8 +11200,8 @@ snapshots:
call-bind: 1.0.7
define-properties: 1.2.1
es-abstract: 1.23.5
es-object-atoms: 1.0.0
get-intrinsic: 1.2.4
es-object-atoms: 1.1.1
get-intrinsic: 1.3.0
is-string: 1.0.7
array.prototype.findlast@1.2.5:
@ -11248,7 +11251,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.23.5
es-errors: 1.3.0
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
is-array-buffer: 3.0.4
is-shared-array-buffer: 1.0.3
@ -11388,10 +11391,10 @@ snapshots:
call-bind@1.0.7:
dependencies:
es-define-property: 1.0.0
es-define-property: 1.0.1
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4:
@ -11676,9 +11679,9 @@ snapshots:
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.0
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.1.0
gopd: 1.2.0
define-properties@1.2.1:
dependencies:
@ -11795,19 +11798,19 @@ snapshots:
data-view-buffer: 1.0.1
data-view-byte-length: 1.0.1
data-view-byte-offset: 1.0.0
es-define-property: 1.0.0
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.0.0
es-set-tostringtag: 2.0.3
es-object-atoms: 1.1.1
es-set-tostringtag: 2.1.0
es-to-primitive: 1.3.0
function.prototype.name: 1.1.6
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
get-symbol-description: 1.0.2
globalthis: 1.0.4
gopd: 1.1.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
has-proto: 1.0.3
has-symbols: 1.0.3
has-symbols: 1.1.0
hasown: 2.0.2
internal-slot: 1.0.7
is-array-buffer: 3.0.4
@ -11837,7 +11840,7 @@ snapshots:
es-define-property@1.0.0:
dependencies:
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
es-define-property@1.0.1: {}
@ -11873,7 +11876,7 @@ snapshots:
es-set-tostringtag@2.0.3:
dependencies:
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
@ -12417,7 +12420,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
es-errors: 1.3.0
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
get-tsconfig@4.8.1:
dependencies:
@ -12506,7 +12509,7 @@ snapshots:
gopd@1.1.0:
dependencies:
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
gopd@1.2.0: {}
@ -12538,7 +12541,7 @@ snapshots:
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.0.3
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
@ -12669,7 +12672,7 @@ snapshots:
is-array-buffer@3.0.4:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
is-arrayish@0.3.2: {}
@ -12753,7 +12756,7 @@ snapshots:
is-regex@1.2.0:
dependencies:
call-bind: 1.0.7
gopd: 1.1.0
gopd: 1.2.0
has-tostringtag: 1.0.2
hasown: 2.0.2
@ -12773,7 +12776,7 @@ snapshots:
is-symbol@1.0.4:
dependencies:
has-symbols: 1.0.3
has-symbols: 1.1.0
is-typed-array@1.1.13:
dependencies:
@ -12809,7 +12812,7 @@ snapshots:
iterator.prototype@1.1.3:
dependencies:
define-properties: 1.2.1
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
has-symbols: 1.0.3
reflect.getprototypeof: 1.0.7
set-function-name: 2.0.2
@ -13199,7 +13202,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
define-properties: 1.2.1
has-symbols: 1.0.3
has-symbols: 1.1.0
object-keys: 1.1.1
object.entries@1.1.8:
@ -13213,7 +13216,7 @@ snapshots:
call-bind: 1.0.7
define-properties: 1.2.1
es-abstract: 1.23.5
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
object.groupby@1.0.3:
dependencies:
@ -13611,8 +13614,8 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.23.5
es-errors: 1.3.0
get-intrinsic: 1.2.4
gopd: 1.1.0
get-intrinsic: 1.3.0
gopd: 1.2.0
which-builtin-type: 1.2.0
regenerate-unicode-properties@10.2.0:
@ -13750,7 +13753,7 @@ snapshots:
safe-array-concat@1.1.2:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
has-symbols: 1.0.3
isarray: 2.0.5
@ -13832,8 +13835,8 @@ snapshots:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.2.4
gopd: 1.1.0
get-intrinsic: 1.3.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
set-function-name@2.0.2:
@ -14051,7 +14054,7 @@ snapshots:
call-bind: 1.0.7
define-properties: 1.2.1
es-abstract: 1.23.5
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
string.prototype.trimend@1.0.8:
dependencies:
@ -14063,7 +14066,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
define-properties: 1.2.1
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
string_decoder@1.1.1:
dependencies:
@ -14357,7 +14360,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
for-each: 0.3.3
gopd: 1.1.0
gopd: 1.2.0
has-proto: 1.0.3
is-typed-array: 1.1.13
@ -14366,7 +14369,7 @@ snapshots:
available-typed-arrays: 1.0.7
call-bind: 1.0.7
for-each: 0.3.3
gopd: 1.1.0
gopd: 1.2.0
has-proto: 1.0.3
is-typed-array: 1.1.13
reflect.getprototypeof: 1.0.7
@ -14375,7 +14378,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
for-each: 0.3.3
gopd: 1.1.0
gopd: 1.2.0
is-typed-array: 1.1.13
possible-typed-array-names: 1.0.0
reflect.getprototypeof: 1.0.7
@ -14388,7 +14391,7 @@ snapshots:
dependencies:
call-bind: 1.0.7
has-bigints: 1.0.2
has-symbols: 1.0.3
has-symbols: 1.1.0
which-boxed-primitive: 1.0.2
undici-types@5.26.5: {}
@ -14727,7 +14730,7 @@ snapshots:
available-typed-arrays: 1.0.7
call-bind: 1.0.7
for-each: 0.3.3
gopd: 1.1.0
gopd: 1.2.0
has-tostringtag: 1.0.2
which@2.0.2: