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

View File

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