feat: add desktop sidebar collapse to icon-only mode (refs #176)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 20:07:00 -06:00
parent f987e94fed
commit bc72f09557
2 changed files with 113 additions and 65 deletions

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useLogout } from '../core/auth/useLogout'; import { useLogout } from '../core/auth/useLogout';
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; import { Container, Paper, Typography, Box, IconButton, Avatar, Tooltip } from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
@@ -15,7 +15,8 @@ import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close'; import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import { useAppStore } from '../core/store'; import { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button'; import { Button } from '../shared-minimal/components/Button';
import { NotificationBell } from '../features/notifications'; import { NotificationBell } from '../features/notifications';
@@ -29,7 +30,7 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => { export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user } = useAuth0(); const { user } = useAuth0();
const { logout } = useLogout(); const { logout } = useLogout();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, toggleSidebarCollapse } = useAppStore();
const location = useLocation(); const location = useLocation();
// Sync theme preference with backend // Sync theme preference with backend
@@ -52,6 +53,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
{ name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> }, { name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
]; ];
const sidebarWidth = sidebarCollapsed ? 64 : 256;
// Mobile layout // Mobile layout
if (mobileMode) { if (mobileMode) {
return ( return (
@@ -107,29 +110,31 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
top: 0, top: 0,
left: 0, left: 0,
height: '100vh', height: '100vh',
width: 256, width: sidebarWidth,
zIndex: 1000, zIndex: 1000,
borderRadius: 0, borderRadius: 0,
borderRight: 1, borderRight: 1,
borderColor: 'divider', borderColor: 'divider',
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)', transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.2s ease-in-out', transition: 'transform 0.2s ease-in-out, width 0.2s ease-in-out',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
overflow: 'hidden',
}} }}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: sidebarCollapsed ? 'center' : 'space-between',
height: 64, height: 64,
px: 2, px: sidebarCollapsed ? 1 : 2,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: 'divider',
gap: 1 gap: 1
}} }}
> >
{!sidebarCollapsed && (
<Box <Box
sx={(theme) => ({ sx={(theme) => ({
backgroundColor: 'primary.main', backgroundColor: 'primary.main',
@@ -140,7 +145,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
px: 1, px: 1,
py: 0.5, py: 0.5,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center' alignItems: 'center',
overflow: 'hidden',
})} })}
> >
<img <img
@@ -149,19 +155,20 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
style={{ height: 24, width: 'auto', maxWidth: 180 }} style={{ height: 24, width: 'auto', maxWidth: 180 }}
/> />
</Box> </Box>
)}
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
size="small" size="small"
sx={{ color: 'text.secondary', flexShrink: 0 }} sx={{ color: 'text.secondary', flexShrink: 0 }}
> >
<CloseIcon /> {sidebarCollapsed ? <ChevronRightRoundedIcon /> : <ChevronLeftRoundedIcon />}
</IconButton> </IconButton>
</Box> </Box>
<Box sx={{ mt: 3, px: 2, flex: 1 }}> <Box sx={{ mt: 3, px: sidebarCollapsed ? 1 : 2, flex: 1 }}>
{navigation.map((item) => { {navigation.map((item) => {
const isActive = location.pathname.startsWith(item.href); const isActive = location.pathname.startsWith(item.href);
return ( const navItem = (
<Link <Link
key={item.name} key={item.name}
to={item.href} to={item.href}
@@ -171,7 +178,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
px: 2, justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
px: sidebarCollapsed ? 1 : 2,
py: 1.5, py: 1.5,
mb: 0.5, mb: 0.5,
borderRadius: 2, borderRadius: 2,
@@ -189,19 +197,47 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
} }
}} }}
> >
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}> <Box sx={{ mr: sidebarCollapsed ? 0 : 2, display: 'flex', alignItems: 'center' }}>
{item.icon} {item.icon}
</Box> </Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}> {!sidebarCollapsed && (
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
{item.name} {item.name}
</Typography> </Typography>
)}
</Box> </Box>
</Link> </Link>
); );
return sidebarCollapsed ? (
<Tooltip key={item.name} title={item.name} placement="right" arrow>
{navItem}
</Tooltip>
) : (
navItem
);
})} })}
</Box> </Box>
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}> <Box sx={{ p: sidebarCollapsed ? 1 : 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
{sidebarCollapsed ? (
<Tooltip title={user?.name || user?.email || 'User'} placement="right" arrow>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600,
mx: 'auto',
cursor: 'pointer',
}}
onClick={() => logout()}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
</Tooltip>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar <Avatar
sx={{ sx={{
@@ -228,13 +264,15 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
> >
Sign Out Sign Out
</Button> </Button>
</>
)}
</Box> </Box>
</Paper> </Paper>
{/* Main content */} {/* Main content */}
<Box <Box
sx={{ sx={{
ml: sidebarOpen ? '256px' : '0', ml: sidebarOpen ? `${sidebarWidth}px` : '0',
transition: 'margin-left 0.2s ease-in-out', transition: 'margin-left 0.2s ease-in-out',
}} }}
> >
@@ -255,7 +293,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
px: 3 px: 3
}}> }}>
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
sx={{ color: 'text.secondary' }} sx={{ color: 'text.secondary' }}
> >
<MenuIcon /> <MenuIcon />

View File

@@ -4,21 +4,31 @@ import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface AppState { interface AppState {
// UI state // UI state
sidebarOpen: boolean; sidebarOpen: boolean;
sidebarCollapsed: boolean;
selectedVehicle: Vehicle | null; selectedVehicle: Vehicle | null;
// Actions // Actions
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapse: () => void;
setSelectedVehicle: (vehicle: Vehicle | null) => void; setSelectedVehicle: (vehicle: Vehicle | null) => void;
} }
const savedCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
// Initial state // Initial state
sidebarOpen: false, sidebarOpen: false,
sidebarCollapsed: savedCollapsed,
selectedVehicle: null, selectedVehicle: null,
// Actions // Actions
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }), setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
toggleSidebarCollapse: () => set((state) => {
const next = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', String(next));
return { sidebarCollapsed: next };
}),
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }), setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
})); }));