From 2d175a331ee398c8343d99f9e503af5d8389c72e Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Sat, 26 Sep 2026 20:18:45 +0200 Subject: [PATCH 1/4] feat: add collapsible sidebar rail on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop sidebar can now collapse to a 64px icon rail via a burger toggle, freeing ~212px of content width. The state persists across sessions in localStorage, and mobile off-canvas behavior is unchanged. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- client/src/__test__/App.test.tsx | 4 +- .../src/__test__/components/Sidebar.test.tsx | 54 +++++++++++++++- .../__test__/context/SidebarProvider.test.tsx | 61 ++++++++++++++++++- .../__test__/views/HomeNavigation.test.tsx | 2 +- client/src/__test__/views/NoteAdd.test.tsx | 4 +- client/src/__test__/views/TaskAdd.test.tsx | 4 +- client/src/components/NavButton/index.tsx | 2 + client/src/components/Sidebar/index.tsx | 38 +++++++----- client/src/components/Sidebar/style.scss | 54 +++++++++++++++- client/src/context/SidebarContext.ts | 2 + client/src/context/SidebarProvider.tsx | 21 ++++++- client/src/layout/PrivateLayout/index.tsx | 6 +- client/src/layout/PrivateLayout/style.css | 4 ++ 13 files changed, 228 insertions(+), 28 deletions(-) diff --git a/client/src/__test__/App.test.tsx b/client/src/__test__/App.test.tsx index c903d34c..307ec3ce 100644 --- a/client/src/__test__/App.test.tsx +++ b/client/src/__test__/App.test.tsx @@ -15,7 +15,9 @@ vi.mock('../api-service/api'); const sidebarContextMock = { currentPage: '/home', - setNewPage: vi.fn() + setNewPage: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn() }; const filterContextMock = { diff --git a/client/src/__test__/components/Sidebar.test.tsx b/client/src/__test__/components/Sidebar.test.tsx index 9a43c732..424dfd1b 100644 --- a/client/src/__test__/components/Sidebar.test.tsx +++ b/client/src/__test__/components/Sidebar.test.tsx @@ -28,16 +28,18 @@ const authContextMock = { const sidebarContextMock = { currentPage: '/home', - setNewPage: vi.fn() + setNewPage: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn() }; describe('Sidebar Component', () => { - const renderSidebar = () => { + const renderSidebar = (sidebarValue = sidebarContextMock) => { return render( - + @@ -64,6 +66,52 @@ describe('Sidebar Component', () => { expect(dashboardElement).not.toBeNull(); expect(dashboardElement!.classList.contains('selected')).toBe(true); }); + + it('should call toggleCollapsed when the collapse button is clicked', () => { + const toggleCollapsed = vi.fn(); + const { getByLabelText } = renderSidebar({ ...sidebarContextMock, toggleCollapsed }); + fireEvent.click(getByLabelText('Collapse sidebar')); + expect(toggleCollapsed).toHaveBeenCalled(); + }); +}); + +describe('Sidebar collapsed rail', () => { + const collapsedContextMock = { + ...sidebarContextMock, + isCollapsed: true + }; + + const renderCollapsedSidebar = () => { + return render( + + + + + + + + + + ); + }; + + it('should render the rail without labels when collapsed', () => { + const { container, queryByText } = renderCollapsedSidebar(); + expect(container.querySelector('.sidebar-collapsed')).not.toBeNull(); + expect(queryByText('Ricardo')).toBeNull(); + expect(queryByText('Logout')).toBeNull(); + }); + + it('should expose native title tooltips on nav items when collapsed', () => { + const { getByTitle } = renderCollapsedSidebar(); + expect(getByTitle('Home')).toBeDefined(); + expect(getByTitle('Logout')).toBeDefined(); + }); + + it('should label the toggle as expand when collapsed', () => { + const { getByLabelText } = renderCollapsedSidebar(); + expect(getByLabelText('Expand sidebar').getAttribute('aria-expanded')).toBe('false'); + }); }); describe('Build link visibility', () => { diff --git a/client/src/__test__/context/SidebarProvider.test.tsx b/client/src/__test__/context/SidebarProvider.test.tsx index e548d0e1..b1f4a9bd 100644 --- a/client/src/__test__/context/SidebarProvider.test.tsx +++ b/client/src/__test__/context/SidebarProvider.test.tsx @@ -10,12 +10,15 @@ import SidebarContext, { SidebarContextData } from '../../context/SidebarContext const ConsumerComponent: React.FC = () => { const { currentPage, - setNewPage + setNewPage, + isCollapsed, + toggleCollapsed } = useContext(SidebarContext); return (
{currentPage}
+
{String(isCollapsed)}
+
); }; @@ -32,6 +43,7 @@ describe('SidebarProvider', () => { // Reset DOM and mocks for each test. beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); }); afterEach(() => { @@ -63,4 +75,51 @@ describe('SidebarProvider', () => { expect(getByTestId('page').textContent).toBe('/home') ); }); + + it('should default to expanded when nothing is persisted', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('collapsed').textContent).toBe('false'); + }); + + it('should initialize collapsed state from localStorage', () => { + localStorage.setItem('SIDEBAR_COLLAPSED', 'true'); + const { getByTestId } = render( + + + + ); + + expect(getByTestId('collapsed').textContent).toBe('true'); + }); + + it('should toggle collapsed state and persist it to localStorage', async () => { + const { getByTestId } = render( + + + + ); + + await act(async () => { + userEvent.click(getByTestId('toggleCollapsed')); + }); + + await waitFor(() => { + expect(getByTestId('collapsed').textContent).toBe('true'); + expect(localStorage.getItem('SIDEBAR_COLLAPSED')).toBe('true'); + }); + + await act(async () => { + userEvent.click(getByTestId('toggleCollapsed')); + }); + + await waitFor(() => { + expect(getByTestId('collapsed').textContent).toBe('false'); + expect(localStorage.getItem('SIDEBAR_COLLAPSED')).toBe('false'); + }); + }); }); diff --git a/client/src/__test__/views/HomeNavigation.test.tsx b/client/src/__test__/views/HomeNavigation.test.tsx index 109a5fe7..8ce18099 100644 --- a/client/src/__test__/views/HomeNavigation.test.tsx +++ b/client/src/__test__/views/HomeNavigation.test.tsx @@ -90,7 +90,7 @@ function TestApp() { return ( - + diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 3eaba508..8885b06a 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -88,7 +88,9 @@ const authContextMock = { const sidebarContextMock = { currentPage: '/home', - setNewPage: vi.fn() + setNewPage: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn() }; // Mock the lang handler diff --git a/client/src/__test__/views/TaskAdd.test.tsx b/client/src/__test__/views/TaskAdd.test.tsx index ad671e1d..acd65a35 100644 --- a/client/src/__test__/views/TaskAdd.test.tsx +++ b/client/src/__test__/views/TaskAdd.test.tsx @@ -59,7 +59,9 @@ const authContextMock = { const sidebarContextMock = { currentPage: '/home', - setNewPage: vi.fn() + setNewPage: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn() }; describe('TaskAdd Component', () => { diff --git a/client/src/components/NavButton/index.tsx b/client/src/components/NavButton/index.tsx index 77120179..72e9496f 100644 --- a/client/src/components/NavButton/index.tsx +++ b/client/src/components/NavButton/index.tsx @@ -8,6 +8,7 @@ interface Props { children: React.ReactElement; className: string; onClick: () => void; + title?: string; } /** @@ -26,6 +27,7 @@ function NavButton(props: React.PropsWithChildren): React.ReactElement { { e.preventDefault(); props.onClick(); diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 7fca01a3..890480b6 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -7,7 +7,7 @@ import SidebarContext from '../../context/SidebarContext'; import NavButton from '../NavButton'; import { env } from '../../env'; import './style.scss'; -import { BoxArrowRight, InfoCircleFill, PersonFill, StarFill } from 'react-bootstrap-icons'; +import { BoxArrowRight, ChevronDoubleRight, InfoCircleFill, Link45deg, List, PersonFill, StarFill } from 'react-bootstrap-icons'; interface Props { isMobileOpen: boolean; @@ -21,7 +21,7 @@ interface Props { */ function Sidebar(props: React.PropsWithChildren): React.ReactNode { const { signOut, user } = useContext(AuthContext); - const { currentPage, setNewPage } = useContext(SidebarContext); + const { currentPage, setNewPage, isCollapsed, toggleCollapsed } = useContext(SidebarContext); const [lastSeen, setLastSeen] = useState(''); const { t } = useTranslation(); const build = `Build: ${env.VITE_BUILD}`; @@ -78,44 +78,53 @@ function Sidebar(props: React.PropsWithChildren): React.ReactNode { -
+
+ +
User icon - {user?.name ? user?.name : 'User'} + {!isCollapsed && {user?.name ? user?.name : 'User'}}
{/* Footer at the bottom */}
diff --git a/client/src/components/Sidebar/style.scss b/client/src/components/Sidebar/style.scss index 03b11c41..e8ca403a 100644 --- a/client/src/components/Sidebar/style.scss +++ b/client/src/components/Sidebar/style.scss @@ -10,7 +10,59 @@ height: 100vh; overflow-y: auto; z-index: 2; - transition: transform 0.3s ease; + transition: transform 0.3s ease, width 0.3s ease; +} + +/* Desktop collapse toggle */ +.sidebar-collapse-toggle { + position: absolute; + top: 12px; + right: 12px; + z-index: 3; + padding: 0.25rem 0.5rem; + color: var(--bs-body-color); + border: none; + background: transparent; + align-items: center; + justify-content: center; +} + +/* Desktop collapsed rail */ +@media (min-width: 992px) { + .sidebar-collapsed { + width: 64px; + + .sidebar-collapse-toggle { + right: auto; + left: 50%; + transform: translateX(-50%); + } + + .sidebar-header { + margin-left: 0; + margin-top: 60px; + text-align: center; + } + + .sidebar-header img { + width: 32px; + height: 32px; + margin-right: 0; + } + + .header-spacer { + display: none; + } + + .sidebar-nav { + display: flex; + justify-content: center; + } + + .sidebar-nav svg { + margin-right: 0; + } + } } /* Mobile sidebar positioning - hidden by default */ diff --git a/client/src/context/SidebarContext.ts b/client/src/context/SidebarContext.ts index 03d074a8..0b07c42c 100644 --- a/client/src/context/SidebarContext.ts +++ b/client/src/context/SidebarContext.ts @@ -3,6 +3,8 @@ import { createContext } from 'react'; export interface SidebarContextData { currentPage: string; setNewPage: (page: string) => void; + isCollapsed: boolean; + toggleCollapsed: () => void; } const SidebarContext = createContext({} as SidebarContextData); diff --git a/client/src/context/SidebarProvider.tsx b/client/src/context/SidebarProvider.tsx index 0e5f9ac9..cb9bc551 100644 --- a/client/src/context/SidebarProvider.tsx +++ b/client/src/context/SidebarProvider.tsx @@ -1,21 +1,36 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import SidebarContext, { SidebarContextData } from './SidebarContext'; +const SIDEBAR_COLLAPSED_KEY = 'SIDEBAR_COLLAPSED'; + interface Props { children: React.ReactNode; } const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Props) => { const [currentPage, setCurrentPage] = useState('/home'); + const [isCollapsed, setIsCollapsed] = useState( + () => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' + ); + + useEffect(() => { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed)); + }, [isCollapsed]); const setNewPage = (page: string): void => { setCurrentPage(page); }; + const toggleCollapsed = (): void => { + setIsCollapsed(prev => !prev); + }; + const contextValue: SidebarContextData = useMemo(() => ({ currentPage, - setNewPage - }), [currentPage, setNewPage]); + setNewPage, + isCollapsed, + toggleCollapsed + }), [currentPage, setNewPage, isCollapsed, toggleCollapsed]); return ( diff --git a/client/src/layout/PrivateLayout/index.tsx b/client/src/layout/PrivateLayout/index.tsx index ceb265db..a6cf057f 100644 --- a/client/src/layout/PrivateLayout/index.tsx +++ b/client/src/layout/PrivateLayout/index.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Outlet } from 'react-router'; import Sidebar from '../../components/Sidebar'; +import SidebarContext from '../../context/SidebarContext'; import './style.css'; /** @@ -14,11 +15,12 @@ import './style.css'; */ function PrivateLayout(): React.ReactNode { const [isMobileOpen, setIsMobileOpen] = useState(false); + const { isCollapsed } = useContext(SidebarContext); return (
-
+
diff --git a/client/src/layout/PrivateLayout/style.css b/client/src/layout/PrivateLayout/style.css index 7117ab8f..ec7c0ce4 100644 --- a/client/src/layout/PrivateLayout/style.css +++ b/client/src/layout/PrivateLayout/style.css @@ -15,6 +15,10 @@ margin-left: 276px; /* Same as sidebar width */ padding: 30px; } + + .main-content.content-sidebar-collapsed { + margin-left: 64px; /* Same as collapsed sidebar rail width */ + } } /* On small screens, content takes full width by default */ From 805f195371b1130cb53fd2f907707e1267f59fb3 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Sun, 27 Sep 2026 19:47:37 +0200 Subject: [PATCH 2/4] fix: center rail icons and remove sidebar horizontal scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero nav and link horizontal padding in the collapsed rail so icons center like the avatar, and clip horizontal overflow to stop the scrollbar. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- client/src/components/Sidebar/index.tsx | 2 +- client/src/components/Sidebar/style.scss | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 890480b6..8fe22132 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -95,7 +95,7 @@ function Sidebar(props: React.PropsWithChildren): React.ReactNode {
-