feat: collapsible sidebar

This commit is contained in:
brian-inspiren 2026-08-06 16:11:37 +08:00
parent 83c0f449f5
commit 771229db18
2 changed files with 120 additions and 34 deletions

View File

@ -9,6 +9,7 @@ import {
Collapse,
rem,
Divider,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
@ -61,6 +62,11 @@ const useStyles = createStyles((theme) => ({
textTransform: 'uppercase',
padding: `${theme.spacing.xs}px ${theme.spacing.md}px`,
},
collapsedLink: {
justifyContent: 'center',
paddingLeft: theme.spacing.xs,
paddingRight: theme.spacing.xs,
},
}));
type SidebarItem = {
@ -94,7 +100,7 @@ const items: SidebarItem[] = [
// { label: 'Settings', href: '/settings', icon: <IconSettings size={16} /> , section: 'Management' },
];
export default function Sidebar() {
export default function Sidebar({ collapsed = false }: { collapsed?: boolean }) {
const theme = useMantineTheme();
const { classes, cx } = useStyles();
const location = window.location.pathname;
@ -110,31 +116,48 @@ export default function Sidebar() {
item.href === location ||
(hasChildren && item.children?.some((c) => c.href === location));
const button = (
<UnstyledButton
className={cx(classes.link, {
[classes.active]: isActive,
[classes.collapsedLink]: collapsed,
})}
style={{ paddingLeft: collapsed ? undefined : rem(16 + depth * 16) }}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (hasChildren && !collapsed) toggle(item.label);
else if (item.href) Inertia.visit(item.href);
}}
>
{item.icon && (
<ThemeIcon size="sm" variant="light" mr={collapsed ? 0 : 'sm'}>
{item.icon}
</ThemeIcon>
)}
{!collapsed && <Text>{item.label}</Text>}
{hasChildren && !collapsed && (
<IconChevronDown
size={16}
className={classes.toggleIcon}
style={{
transform: opened[item.label] ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</UnstyledButton>
);
return (
<div key={item.label}>
<UnstyledButton
className={cx(classes.link, { [classes.active]: isActive })}
style={{ paddingLeft: rem(16 + depth * 16) }}
onClick={(e) => {
e.preventDefault();
if (hasChildren) toggle(item.label);
else if (item.href) Inertia.visit(item.href);
}}
>
{item.icon && <ThemeIcon size="sm" variant="light" mr="sm">{item.icon}</ThemeIcon>}
<Text>{item.label}</Text>
{hasChildren && (
<IconChevronDown
size={16}
className={classes.toggleIcon}
style={{
transform: opened[item.label] ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</UnstyledButton>
{collapsed ? (
<Tooltip label={item.label} position="right" withinPortal>
{button}
</Tooltip>
) : (
button
)}
{hasChildren && (
{hasChildren && !collapsed && (
<Collapse in={opened[item.label]}>
{item.children!.map((child) => renderItem(child, depth + 1))}
</Collapse>
@ -147,7 +170,7 @@ export default function Sidebar() {
return (
<Navbar
width={{ base: 280 }}
width={{ base: collapsed ? 60 : 280 }}
p="xs"
style={{
backgroundColor:
@ -160,16 +183,19 @@ export default function Sidebar() {
: theme.colors.gray[2]
}`,
minHeight: '100%',
transition: 'width 160ms ease',
}}
>
<Stack spacing="xs">
{sections.map((section) => (
<div key={section}>
<Text className={classes.sectionTitle}>{section}</Text>
{!collapsed && (
<Text className={classes.sectionTitle}>{section}</Text>
)}
{items
.filter((item) => item.section === section)
.map((item) => renderItem(item))}
<Divider my="xs" />
{!collapsed && <Divider my="xs" />}
</div>
))}
</Stack>

View File

@ -39,6 +39,8 @@ import {
IconBell,
IconCircleX,
IconInfoCircle,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconLogout,
IconSettings,
IconUserPlus,
@ -1083,7 +1085,14 @@ type Props = {
export default function AppLayout({ children }: Props) {
const theme = useMantineTheme();
const [opened, setOpened] = React.useState(false);
const [mobileOpened, setMobileOpened] = React.useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem('sidebar_collapsed') === 'true';
});
const isDark = theme.colorScheme === 'dark';
@ -1117,26 +1126,38 @@ export default function AppLayout({ children }: Props) {
});
}
}, [flash]);
useEffect(() => {
window.localStorage.setItem(
'sidebar_collapsed',
String(sidebarCollapsed),
);
}, [sidebarCollapsed]);
return (
<SidebarProvider open={opened} onOpenChange={setOpened}>
<SidebarProvider
open={!sidebarCollapsed}
onOpenChange={(open) => setSidebarCollapsed(!open)}
>
<AppShell
padding="lg"
navbarOffsetBreakpoint="sm"
navbar={
<Navbar
width={{ base: 260 }}
width={{ base: sidebarCollapsed ? 76 : 260 }}
hiddenBreakpoint="sm"
hidden={!opened}
p="md"
hidden={!mobileOpened}
p={sidebarCollapsed ? 'xs' : 'md'}
style={{
borderRight: `1px solid ${
isDark
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
transition: 'width 160ms ease',
}}
>
<Sidebar />
<Sidebar collapsed={sidebarCollapsed} />
</Navbar>
}
header={
@ -1165,11 +1186,50 @@ export default function AppLayout({ children }: Props) {
styles={{ display: 'none' }}
>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
opened={mobileOpened}
onClick={() =>
setMobileOpened((o) => !o)
}
size="sm"
/>
</MediaQuery>
<MediaQuery
smallerThan="sm"
styles={{ display: 'none' }}
>
<Tooltip
label={
sidebarCollapsed
? 'Expand sidebar'
: 'Collapse sidebar'
}
withinPortal
>
<ActionIcon
variant="subtle"
onClick={() =>
setSidebarCollapsed(
(collapsed) => !collapsed,
)
}
aria-label={
sidebarCollapsed
? 'Expand sidebar'
: 'Collapse sidebar'
}
>
{sidebarCollapsed ? (
<IconLayoutSidebarLeftExpand
size={18}
/>
) : (
<IconLayoutSidebarLeftCollapse
size={18}
/>
)}
</ActionIcon>
</Tooltip>
</MediaQuery>
<Text weight={700} size="lg">
Inspiren SEM Tool