From 0c4b523d058b48962b7ca2f6bdd8765c373ffd09 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 17:00:03 +0100 Subject: [PATCH] feat: implement mobile overlay sidebar with focus trap and ESC handling --- src/components/BookmarkList.tsx | 7 +- src/components/Bookmarks.tsx | 22 +++++- src/components/SidebarHeader.tsx | 32 ++++++--- src/components/ThreePaneLayout.tsx | 106 ++++++++++++++++++++++++++++- 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index f4bf67be..ba4729cf 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -27,6 +27,8 @@ interface BookmarkListProps { loading?: boolean relayPool: RelayPool | null settings?: UserSettings + isMobile?: boolean + isSidebarOpen?: boolean } export const BookmarkList: React.FC = ({ @@ -44,7 +46,9 @@ export const BookmarkList: React.FC = ({ lastFetchTime, loading = false, relayPool, - settings + settings, + isMobile = false, + isSidebarOpen = false }) => { // Helper to check if a bookmark has either content or a URL const hasContentOrUrl = (ib: IndividualBookmark) => { @@ -106,6 +110,7 @@ export const BookmarkList: React.FC = ({ onLogout={onLogout} onOpenSettings={onOpenSettings} relayPool={relayPool} + isMobile={isMobile} /> {loading ? ( diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index c65af17e..9657fcba 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -67,6 +67,9 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { }) const { + isMobile, + isSidebarOpen, + toggleSidebar, isCollapsed, setIsCollapsed, isHighlightsCollapsed, @@ -116,7 +119,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setReaderLoading, readerContent, setReaderContent, - handleSelectUrl + handleSelectUrl: baseHandleSelectUrl } = useContentSelection({ relayPool, settings, @@ -125,6 +128,14 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setCurrentArticle }) + // Wrap handleSelectUrl to close mobile sidebar when selecting content + const handleSelectUrl = (url: string, bookmark?: any) => { + if (isMobile && isSidebarOpen) { + toggleSidebar() + } + baseHandleSelectUrl(url, bookmark) + } + const { highlightButtonRef, handleTextSelection, @@ -180,6 +191,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { = ({ relayPool, onLogout }) => { viewMode={viewMode} isRefreshing={isRefreshing} lastFetchTime={lastFetchTime} - onToggleSidebar={() => setIsCollapsed(!isCollapsed)} + onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)} onLogout={onLogout} onViewModeChange={setViewMode} onOpenSettings={() => { navigate('/settings') - setIsCollapsed(true) + if (isMobile) { + toggleSidebar() + } else { + setIsCollapsed(true) + } setIsHighlightsCollapsed(true) }} onRefresh={handleRefreshAll} diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index 2bcb76f6..c3f092d1 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { useNavigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } from '@fortawesome/free-solid-svg-icons' +import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { useEventModel } from 'applesauce-react/hooks' import { Models } from 'applesauce-core' @@ -17,9 +17,10 @@ interface SidebarHeaderProps { onLogout: () => void onOpenSettings: () => void relayPool: RelayPool | null + isMobile?: boolean } -const SidebarHeader: React.FC = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => { +const SidebarHeader: React.FC = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => { const [isConnecting, setIsConnecting] = useState(false) const [showAddModal, setShowAddModal] = useState(false) const navigate = useNavigate() @@ -66,14 +67,25 @@ const SidebarHeader: React.FC = ({ onToggleCollapse, onLogou return ( <>
- + {isMobile ? ( + + ) : ( + + )}
= (props) => { + const isMobile = useIsMobile() + const sidebarRef = useRef(null) + + // Lock body scroll when mobile sidebar is open + useEffect(() => { + if (isMobile && props.isSidebarOpen) { + document.body.classList.add('mobile-sidebar-open') + } else { + document.body.classList.remove('mobile-sidebar-open') + } + + return () => { + document.body.classList.remove('mobile-sidebar-open') + } + }, [isMobile, props.isSidebarOpen]) + + // Handle ESC key to close sidebar + useEffect(() => { + if (!isMobile || !props.isSidebarOpen) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + props.onToggleSidebar() + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isMobile, props.isSidebarOpen, props.onToggleSidebar]) + + // Trap focus in sidebar when open on mobile + useEffect(() => { + if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return + + const sidebar = sidebarRef.current + const focusableElements = sidebar.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + const firstElement = focusableElements[0] + const lastElement = focusableElements[focusableElements.length - 1] + + const handleTab = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault() + lastElement?.focus() + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault() + firstElement?.focus() + } + } + } + + sidebar.addEventListener('keydown', handleTab) + firstElement?.focus() + + return () => { + sidebar.removeEventListener('keydown', handleTab) + } + }, [isMobile, props.isSidebarOpen]) + + const handleBackdropClick = () => { + if (isMobile && props.isSidebarOpen) { + props.onToggleSidebar() + } + } + return ( <> + {/* Mobile hamburger button */} + {isMobile && !props.isSidebarOpen && ( + + )} + + {/* Mobile backdrop */} + {isMobile && ( +