fix: improve responsive library sidebar

This commit is contained in:
akai
2026-07-10 09:20:29 +08:00
parent 4993053d03
commit 2c4085815e
13 changed files with 266 additions and 52 deletions
@@ -29,6 +29,18 @@ const renderDropdown = () =>
</DropdownProvider>,
);
const renderTwoDropdowns = () =>
render(
<DropdownProvider>
<Dropdown label='First Menu' toggleButton={<span>First</span>} showTooltip={false}>
<div>First content</div>
</Dropdown>
<Dropdown label='Second Menu' toggleButton={<span>Second</span>} showTooltip={false}>
<div>Second content</div>
</Dropdown>
</DropdownProvider>,
);
describe('Dropdown keyboard activation', () => {
it('opens when Enter is pressed on the toggle button', () => {
renderDropdown();
@@ -91,4 +103,29 @@ describe('Dropdown keyboard activation', () => {
window.removeEventListener('keydown', onWindowKeyDown);
}
});
it('closes when the pointer is pressed outside', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
fireEvent.pointerDown(document.body);
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
it('switches directly to another dropdown on the first click', () => {
renderTwoDropdowns();
const first = screen.getByRole('button', { name: 'First Menu' });
const second = screen.getByRole('button', { name: 'Second Menu' });
fireEvent.click(first);
expect(first.getAttribute('aria-expanded')).toBe('true');
fireEvent.pointerDown(second);
fireEvent.click(second);
expect(first.getAttribute('aria-expanded')).toBe('false');
expect(second.getAttribute('aria-expanded')).toBe('true');
});
});
@@ -126,7 +126,7 @@ const BookItem: React.FC<BookItemProps> = ({
role='none'
className={clsx(
'book-item flex',
mode === 'grid' && 'h-full flex-col justify-end',
mode === 'grid' && 'h-full w-full flex-col justify-end',
mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden',
mode === 'list' ? 'library-list-item' : 'library-grid-item',
appService?.hasContextMenu ? 'cursor-pointer' : '',
@@ -90,6 +90,7 @@ interface BookshelfProps {
handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null };
categoryFilter?: LibraryCategoryFilter;
sidebarVisible?: boolean;
}
/**
@@ -100,6 +101,7 @@ interface BookshelfProps {
type BookshelfListContext = {
autoColumns: boolean;
fixedColumns: number;
sidebarVisible: boolean;
/**
* The recently-read shelf, rendered in the Virtuoso header so it scrolls with
* the shelf content (not sticky). `null` when hidden. Passed through context
@@ -109,9 +111,12 @@ type BookshelfListContext = {
recentShelfHeader: React.ReactNode;
};
const BOOKSHELF_GRID_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
const BOOKSHELF_GRID_BASE_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2';
const BOOKSHELF_GRID_DEFAULT_COLUMNS =
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
const BOOKSHELF_GRID_SIDEBAR_COLUMNS =
'grid-cols-3 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
@@ -122,7 +127,11 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
<div
ref={ref}
data-testid={testId}
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
className={clsx(
BOOKSHELF_GRID_BASE_CLASSES,
context?.sidebarVisible ? BOOKSHELF_GRID_SIDEBAR_COLUMNS : BOOKSHELF_GRID_DEFAULT_COLUMNS,
className,
)}
style={{
...style,
gridTemplateColumns:
@@ -178,6 +187,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handlePushLibrary,
booksTransferProgress,
categoryFilter = 'all',
sidebarVisible = false,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -849,6 +859,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit={coverFit as LibraryCoverFitType}
autoColumns={settings.libraryAutoColumns}
fixedColumns={settings.libraryColumns}
sidebarVisible={sidebarVisible}
onOpenBook={openRecentBook}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
@@ -861,6 +872,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit,
settings.libraryAutoColumns,
settings.libraryColumns,
sidebarVisible,
openRecentBook,
handleBookUpload,
handleBookDownload,
@@ -872,9 +884,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
() => ({
autoColumns: settings.libraryAutoColumns,
fixedColumns: settings.libraryColumns,
sidebarVisible,
recentShelfHeader,
}),
[settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader],
[settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader],
);
const renderBookshelfItem = useCallback(
@@ -22,6 +22,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const isSingleBookGrid = mode === 'grid' && group.books.length === 1;
const checkScrollArrows = () => {
if (mode === 'list' && scrollContainerRef.current) {
@@ -115,7 +116,10 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div
ref={mode === 'list' ? scrollContainerRef : undefined}
className={clsx(
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden',
mode === 'grid' &&
(isSingleBookGrid
? 'flex w-full overflow-hidden'
: 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'),
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
mode === 'list' ? 'library-list-item' : 'library-grid-item',
)}
@@ -137,7 +141,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
key={book.hash}
className={clsx(
'relative aspect-[28/41] h-full',
mode === 'grid' && 'w-full',
mode === 'grid' && (isSingleBookGrid ? 'mx-auto' : 'w-full'),
mode === 'list' && 'flex-shrink-0',
)}
>
@@ -8,6 +8,7 @@ import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
menuClassName?: string;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onImportBookFromUrl?: () => void;
@@ -16,6 +17,7 @@ interface ImportMenuProps {
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
menuClassName,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onImportBookFromUrl,
@@ -46,7 +48,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx('dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow')}
className={clsx(
'dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow',
menuClassName,
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
@@ -14,6 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
@@ -33,6 +34,7 @@ interface LibraryHeaderProps {
onToggleSelectMode: () => void;
onSelectAll: () => void;
onDeselectAll: () => void;
onToggleSidebar: () => void;
}
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
@@ -46,6 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
onSelectAll,
onDeselectAll,
onToggleSidebar,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -59,6 +62,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const { isTrafficLightVisible } = useTrafficLight(headerRef);
const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore();
const isNarrowViewport = useIsMobileViewport(641);
useShortcuts({
onToggleSelectMode,
@@ -92,7 +96,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
if (!insets) return null;
const isMobile = appService?.isMobile || window.innerWidth <= 640;
const isMobile = appService?.isMobile || isNarrowViewport;
return (
<div
@@ -109,8 +113,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center gap-2 pl-2 sm:pl-4'>
<button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 p-0'
aria-label={_('Toggle Sidebar')}
title={_('Toggle Sidebar')}
onClick={onToggleSidebar}
>
<MdOutlineMenu role='none' size={iconSize18} />
</button>
<div className='relative flex h-9 min-w-0 flex-1 items-center sm:h-7'>
<span className='text-base-content/50 absolute ps-3'>
<FaSearch className='h-4 w-4' />
</span>
@@ -44,6 +44,8 @@ interface LibrarySidebarProps {
onImportBookFromUrl?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onToggleSidebar: () => void;
isViewportResolved: boolean;
}
type CategoryItem = {
@@ -63,6 +65,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
onImportBookFromUrl,
onOpenCatalogManager,
onToggleSelectMode,
onToggleSidebar,
isViewportResolved,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -160,13 +164,22 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
return (
<aside
className={clsx(
'library-sidebar bg-base-200/80 border-base-300/70 hidden h-full w-[248px] shrink-0 flex-col border-r md:flex',
'library-sidebar bg-base-200/95 border-base-300/70 fixed inset-y-0 start-0 z-50 h-full w-[min(82vw,248px)] shrink-0 flex-col border-r md:relative md:inset-auto md:z-auto md:w-[248px]',
isViewportResolved ? 'flex' : 'hidden md:flex',
'pt-[max(env(safe-area-inset-top),0px)]',
)}
>
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
<MdOutlineMenu className='text-base-content/70 h-5 w-5 shrink-0' />
<button
type='button'
className='btn btn-ghost h-7 min-h-7 w-7 shrink-0 p-0'
aria-label={_('Toggle Sidebar')}
title={_('Toggle Sidebar')}
onClick={onToggleSidebar}
>
<MdOutlineMenu className='text-base-content/70 h-5 w-5' />
</button>
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
</div>
@@ -268,6 +281,7 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
<Dropdown
label={_('Import Books')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiPlus role='none' size={iconSize18} />}
>
@@ -280,7 +294,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
</Dropdown>
<Dropdown
label={_('View Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
className='exclude-title-bar-mousedown dropdown-top dropdown-center'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
>
@@ -301,7 +316,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
<div className='flex-1' />
<Dropdown
label={_('Settings Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
className='exclude-title-bar-mousedown dropdown-top dropdown-end'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiGear role='none' size={iconSize18} />}
>
@@ -19,6 +19,7 @@ interface RecentShelfProps {
// Mirror the bookshelf grid's column model so covers are the same size.
autoColumns: boolean;
fixedColumns: number;
sidebarVisible?: boolean;
onOpenBook: (book: Book) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
@@ -110,6 +111,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
coverFit,
autoColumns,
fixedColumns,
sidebarVisible = false,
onOpenBook,
handleBookUpload,
handleBookDownload,
@@ -121,7 +123,9 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
// `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula
// subtracts the right gap at each breakpoint.
const colsClass = autoColumns
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
? sidebarVisible
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:3] lg:[--rs-cols:4] xl:[--rs-cols:5] 2xl:[--rs-cols:8]'
: '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
: '';
const colsStyle = autoColumns
? undefined
@@ -162,7 +166,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, books, autoColumns, fixedColumns, coverFit]);
}, [measure, books, autoColumns, fixedColumns, sidebarVisible, coverFit]);
const scrollByPage = (direction: -1 | 1) => {
const el = scrollerRef.current;
@@ -47,9 +47,14 @@ import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
interface SettingsMenuProps {
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
const SettingsMenu: React.FC<SettingsMenuProps> = ({
onPullLibrary,
setIsDropdownOpen,
menuClassName,
}) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -318,6 +323,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
className={clsx(
'settings-menu dropdown-content no-triangle',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
menuClassName,
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
@@ -1,4 +1,5 @@
import React from 'react';
import clsx from 'clsx';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
@@ -15,17 +16,21 @@ import { navigateToLibrary } from '@/utils/nav';
import NumberInput from '@/components/settings/NumberInput';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
interface ViewMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
}
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const isPhoneViewport = useIsMobileViewport();
const isCompactViewport = useIsMobileViewport(1024);
const viewMode = settings.libraryViewMode;
const coverFit = settings.libraryCoverFit;
@@ -170,7 +175,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
className={clsx('view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl', menuClassName)}
onCancel={() => setIsDropdownOpen?.(false)}
>
{/* View Mode */}
@@ -201,11 +206,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
value={columns}
disabled={viewMode === 'list'}
onChange={handleSetColumns}
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3}
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12}
min={isPhoneViewport ? 1 : isCompactViewport ? 2 : 3}
max={isPhoneViewport ? 4 : isCompactViewport ? 6 : 12}
/>
}
onClick={() => handleToggleAutoColumns()}
transient
/>
{/* Book Covers */}
+113 -23
View File
@@ -122,6 +122,7 @@ import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
import { Overlay } from '@/components/Overlay';
/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
@@ -158,6 +159,19 @@ const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
* dialog forces the toggle ON regardless of this value.
*/
const LAST_IMPORT_FOLDER_READ_IN_PLACE_KEY = 'readest:lastImportFolderReadInPlace';
const LIBRARY_SIDEBAR_VISIBLE_KEY = 'readest:librarySidebarVisible';
const readLibrarySidebarVisibility = (): boolean | null => {
if (typeof window === 'undefined') return null;
try {
const stored = window.localStorage.getItem(LIBRARY_SIDEBAR_VISIBLE_KEY);
if (stored === '1') return true;
if (stored === '0') return false;
} catch {
return null;
}
return null;
};
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -218,6 +232,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null);
const [isDesktopLibraryViewport, setDesktopLibraryViewport] = useState(true);
const [isLibrarySidebarDocked, setLibrarySidebarDockedState] = useState(true);
const [isLibrarySidebarDrawerOpen, setLibrarySidebarDrawerOpen] = useState(false);
const [isLibraryViewportResolved, setLibraryViewportResolved] = useState(false);
// "Import from folder" dialog state. Held as a small object rather
// than a boolean because we need a default starting directory to seed
// the path field, and we want the dialog to remain mounted long
@@ -300,6 +318,51 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useClipUrlIngress();
useTransferQueue(libraryLoaded);
const setLibrarySidebarDocked = useCallback((visible: boolean) => {
setLibrarySidebarDockedState(visible);
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(LIBRARY_SIDEBAR_VISIBLE_KEY, visible ? '1' : '0');
} catch {
// localStorage can be unavailable in constrained webviews; the in-memory
// state still gives the user a working toggle for this session.
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(min-width: 768px)');
const syncSidebarMode = () => {
const isDesktop = mediaQuery.matches;
setDesktopLibraryViewport(isDesktop);
setLibrarySidebarDrawerOpen(false);
if (isDesktop) {
setLibrarySidebarDockedState(readLibrarySidebarVisibility() ?? true);
}
setLibraryViewportResolved(true);
};
syncSidebarMode();
mediaQuery.addEventListener('change', syncSidebarMode);
return () => mediaQuery.removeEventListener('change', syncSidebarMode);
}, []);
const openLibrarySidebar = useCallback(() => {
if (isDesktopLibraryViewport) {
setLibrarySidebarDocked(true);
} else {
setLibrarySidebarDrawerOpen(true);
}
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
const closeLibrarySidebar = useCallback(() => {
if (isDesktopLibraryViewport) {
setLibrarySidebarDocked(false);
} else {
setLibrarySidebarDrawerOpen(false);
}
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
const { pullLibrary, pushLibrary } = useBooksSync();
// Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
// Google Drive): keeps library.json current on import / delete / book-close,
@@ -1595,6 +1658,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
const isLibrarySidebarVisible = isDesktopLibraryViewport
? isLibrarySidebarDocked
: isLibrarySidebarDrawerOpen;
const isLibrarySidebarDockedVisible = isDesktopLibraryViewport && isLibrarySidebarDocked;
const showLibraryHeader = !isLibrarySidebarVisible;
return (
<div
@@ -1606,21 +1674,39 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<LibrarySidebar
books={libraryBooks}
isSelectMode={isSelectMode}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
/>
{isLibrarySidebarVisible && (
<>
{!isDesktopLibraryViewport && (
<Overlay className='z-40 bg-black/20' onDismiss={closeLibrarySidebar} />
)}
<LibrarySidebar
books={libraryBooks}
isSelectMode={isSelectMode}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onToggleSidebar={closeLibrarySidebar}
isViewportResolved={isLibraryViewportResolved}
/>
</>
)}
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<div
className='relative top-0 z-40 w-full md:hidden'
className={clsx(
'relative top-0 z-40 w-full',
isLibraryViewportResolved
? showLibraryHeader
? 'block'
: 'hidden'
: 'block md:hidden',
)}
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
@@ -1640,6 +1726,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onToggleSidebar={openLibrarySidebar}
/>
<progress
aria-label={_('Library Sync Progress')}
@@ -1652,16 +1739,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
max='100'
/>
</div>
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
{!showLibraryHeader && (
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
)}
{(loading || isSyncing) && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
@@ -1739,6 +1828,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleLibraryNavigation={handleLibraryNavigation}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
sidebarVisible={isLibrarySidebarDockedVisible}
/>
</div>
</div>
+26 -4
View File
@@ -1,7 +1,14 @@
import clsx from 'clsx';
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react';
import React, {
useState,
isValidElement,
ReactElement,
ReactNode,
useRef,
useId,
useEffect,
} from 'react';
import { useDropdownContext } from '@/context/DropdownContext';
import { Overlay } from './Overlay';
import MenuItem from './MenuItem';
interface DropdownProps {
@@ -74,6 +81,7 @@ const Dropdown: React.FC<DropdownProps> = ({
const dropdownId = useId();
const context = useDropdownContext();
const isOpen = context ? context.openDropdownId === dropdownId : false;
const hasOpenDropdown = context ? context.openDropdownId !== null : isOpen;
const containerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
@@ -108,6 +116,21 @@ const Dropdown: React.FC<DropdownProps> = ({
}
};
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (containerRef.current?.contains(event.target as Node)) return;
if (event.target instanceof Element && event.target.closest('.dropdown-container') !== null) {
return;
}
setIsDropdownOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const childrenWithToggle = isValidElement(children)
? React.cloneElement(children, {
...(typeof children.type !== 'string' && {
@@ -120,8 +143,7 @@ const Dropdown: React.FC<DropdownProps> = ({
return (
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
<div className={clsx('relative', isOpen && 'z-50')}>
<div className={clsx('relative', isOpen ? 'z-[60]' : hasOpenDropdown && 'z-[70]')}>
<button
tabIndex={0}
aria-haspopup='menu'
@@ -1,9 +1,7 @@
import { useEffect, useState } from 'react';
export const useIsMobileViewport = (breakpoint = 640) => {
const readViewport = () =>
typeof window === 'undefined' ? false : window.innerWidth < breakpoint;
const [isMobileViewport, setIsMobileViewport] = useState(readViewport);
const [isMobileViewport, setIsMobileViewport] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;