diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f085a4..068c7bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2026-09-27 +### Added +- Collapse function to left panel. [PR 90](https://github.com/thermcampos/tasknote/pull/90) + ### Fixed - Logout cache cleaning. [PR 89](https://github.com/thermcampos/tasknote/pull/89) 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..5103fae1 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, ChevronDoubleLeft, ChevronDoubleRight, InfoCircleFill, Link45deg, 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'}}
-
diff --git a/client/src/components/Sidebar/style.scss b/client/src/components/Sidebar/style.scss index 03b11c41..d3f428fe 100644 --- a/client/src/components/Sidebar/style.scss +++ b/client/src/components/Sidebar/style.scss @@ -10,7 +10,66 @@ 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; + overflow-x: hidden; + + .sidebar-collapse-toggle { + right: auto; + left: 50%; + transform: translateX(-50%); + } + + .sidebar-header { + margin-left: 0; + margin-top: 60px; + margin-bottom: 16px; + 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; + } + } + + .sidebar.sidebar-collapsed a { + padding-left: 0; + padding-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 */