From 2c4085815e1bb47b1b52d3c4b12fb7239a79b166 Mon Sep 17 00:00:00 2001 From: akai Date: Fri, 10 Jul 2026 09:20:29 +0800 Subject: [PATCH] fix: improve responsive library sidebar --- .../__tests__/components/Dropdown.test.tsx | 37 +++++ .../src/app/library/components/BookItem.tsx | 2 +- .../src/app/library/components/Bookshelf.tsx | 21 ++- .../src/app/library/components/GroupItem.tsx | 8 +- .../src/app/library/components/ImportMenu.tsx | 7 +- .../app/library/components/LibraryHeader.tsx | 19 ++- .../app/library/components/LibrarySidebar.tsx | 24 +++- .../app/library/components/RecentShelf.tsx | 8 +- .../app/library/components/SettingsMenu.tsx | 8 +- .../src/app/library/components/ViewMenu.tsx | 14 +- apps/readest-app/src/app/library/page.tsx | 136 +++++++++++++++--- apps/readest-app/src/components/Dropdown.tsx | 30 +++- .../src/hooks/useIsMobileViewport.ts | 4 +- 13 files changed, 266 insertions(+), 52 deletions(-) diff --git a/apps/readest-app/src/__tests__/components/Dropdown.test.tsx b/apps/readest-app/src/__tests__/components/Dropdown.test.tsx index f783547f..b46528e8 100644 --- a/apps/readest-app/src/__tests__/components/Dropdown.test.tsx +++ b/apps/readest-app/src/__tests__/components/Dropdown.test.tsx @@ -29,6 +29,18 @@ const renderDropdown = () => , ); +const renderTwoDropdowns = () => + render( + + First} showTooltip={false}> +
First content
+
+ Second} showTooltip={false}> +
Second content
+
+
, + ); + 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'); + }); }); diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index 0d4cda52..8345631c 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -126,7 +126,7 @@ const BookItem: React.FC = ({ 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' : '', diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index c2f35e14..3e431529 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -90,6 +90,7 @@ interface BookshelfProps { handlePushLibrary: () => Promise; 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['List'] = React.fo
= ({ handlePushLibrary, booksTransferProgress, categoryFilter = 'all', + sidebarVisible = false, }) => { const _ = useTranslation(); const router = useRouter(); @@ -849,6 +859,7 @@ const Bookshelf: React.FC = ({ 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 = ({ coverFit, settings.libraryAutoColumns, settings.libraryColumns, + sidebarVisible, openRecentBook, handleBookUpload, handleBookDownload, @@ -872,9 +884,10 @@ const Bookshelf: React.FC = ({ () => ({ autoColumns: settings.libraryAutoColumns, fixedColumns: settings.libraryColumns, + sidebarVisible, recentShelfHeader, }), - [settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader], + [settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader], ); const renderBookshelfItem = useCallback( diff --git a/apps/readest-app/src/app/library/components/GroupItem.tsx b/apps/readest-app/src/app/library/components/GroupItem.tsx index e3f282a3..1722fb6f 100644 --- a/apps/readest-app/src/app/library/components/GroupItem.tsx +++ b/apps/readest-app/src/app/library/components/GroupItem.tsx @@ -22,6 +22,7 @@ const GroupItem: React.FC = ({ mode, group, isSelectMode, groupS const scrollContainerRef = useRef(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 = ({ mode, group, isSelectMode, groupS
= ({ 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', )} > diff --git a/apps/readest-app/src/app/library/components/ImportMenu.tsx b/apps/readest-app/src/app/library/components/ImportMenu.tsx index 041288bc..7c57fbaa 100644 --- a/apps/readest-app/src/app/library/components/ImportMenu.tsx +++ b/apps/readest-app/src/app/library/components/ImportMenu.tsx @@ -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 = ({ setIsDropdownOpen, + menuClassName, onImportBooksFromFiles, onImportBooksFromDirectory, onImportBookFromUrl, @@ -46,7 +48,10 @@ const ImportMenu: React.FC = ({ return ( setIsDropdownOpen?.(false)} > void; onSelectAll: () => void; onDeselectAll: () => void; + onToggleSidebar: () => void; } const LibraryHeader: React.FC = ({ @@ -46,6 +48,7 @@ const LibraryHeader: React.FC = ({ onToggleSelectMode, onSelectAll, onDeselectAll, + onToggleSidebar, }) => { const _ = useTranslation(); const router = useRouter(); @@ -59,6 +62,7 @@ const LibraryHeader: React.FC = ({ 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 = ({ if (!insets) return null; - const isMobile = appService?.isMobile || window.innerWidth <= 640; + const isMobile = appService?.isMobile || isNarrowViewport; return (
= ({ }} >
-
-
+
+ +
diff --git a/apps/readest-app/src/app/library/components/LibrarySidebar.tsx b/apps/readest-app/src/app/library/components/LibrarySidebar.tsx index 4f02bacd..384f6fb0 100644 --- a/apps/readest-app/src/app/library/components/LibrarySidebar.tsx +++ b/apps/readest-app/src/app/library/components/LibrarySidebar.tsx @@ -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 = ({ onImportBookFromUrl, onOpenCatalogManager, onToggleSelectMode, + onToggleSidebar, + isViewportResolved, }) => { const _ = useTranslation(); const router = useRouter(); @@ -160,13 +164,22 @@ const LibrarySidebar: React.FC = ({ return (