Skip to content

Commit 0f43534

Browse files
authored
feat: Make menu auto-fit within screen (#1816)
1 parent a9d88a3 commit 0f43534

7 files changed

Lines changed: 167 additions & 174 deletions

File tree

src/layout/sidebar/SideBar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Drawer, Layout } from 'antd'
2-
import cn from 'classnames'
32
import { useContext, useState } from 'react'
43
import { useTranslation } from 'react-i18next'
54
import { LayoutContextInterface, LayoutCtx } from '../LayoutContext'
@@ -23,7 +22,7 @@ export default function SideBar() {
2322
size={280}
2423
onClose={() => setDrawerOpen(false)}
2524
open={drawerOpen}
26-
rootClassName={cn('hideOnDesktop', { dark: isDarkTheme })}
25+
rootClassName="hideOnDesktop"
2726
styles={{ body: { padding: '0' } }}>
2827
<Menu />
2928
</Drawer>
@@ -35,12 +34,13 @@ export default function SideBar() {
3534
collapsible
3635
collapsed={collapsed}
3736
style={{
38-
marginBottom: '48px',
37+
// No bottom margin for the fixed trigger: antd already reserves its 48px as
38+
// padding on the sider, and reserving it twice cost the menu a row of height.
3939
boxShadow: isDarkTheme ? '0 0 12px 4px rgba(0,0,0,0.7)' : '0 0 12px 4px rgba(0,0,0,0.12)',
4040
}}
4141
onCollapse={setCollapsed}
42-
className={cn('hideOnMobile', { dark: isDarkTheme })}>
43-
<Menu collapsed={collapsed} />
42+
className="hideOnMobile">
43+
<Menu collapsed={collapsed} compact />
4444
</Sider>
4545
</>
4646
)

src/layout/sidebar/menu/Menu.tsx

Lines changed: 155 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
import type { MenuProps } from 'antd'
2-
import { Menu } from 'antd'
3-
import React, { useContext, useEffect, useState } from 'react'
1+
import {
2+
List,
3+
ListItem,
4+
ListItemButton,
5+
ListItemIcon,
6+
ListItemText,
7+
ListSubheader,
8+
Tooltip,
9+
} from '@mui/material'
10+
import { styled } from '@mui/material/styles'
11+
import React, { useContext, useState } from 'react'
412
import { useTranslation } from 'react-i18next'
513
import { Link, useLocation } from 'react-router'
614
import { LayoutContextInterface, LayoutCtx } from 'src/layout/LayoutContext'
715
import DonateModal from 'src/pages/DonateModal/DonateModal'
816
import { PAGES } from 'src/routes'
9-
import './menu.scss'
1017

11-
type MenuItem = Required<MenuProps>['items'][number]
1218
type MainMenuProps = {
1319
collapsed?: boolean
20+
/* Density only — both the sider and the drawer fit themselves to the space they get
21+
(see NavList). The sider is driven by a mouse and shares its screen with the page, so
22+
it starts tighter; the drawer owns the whole viewport and is touched, so it starts
23+
roomy. */
24+
compact?: boolean
1425
}
1526

1627
const MENU_GROUPS = [
@@ -36,103 +47,163 @@ const MENU_GROUPS = [
3647
},
3748
] as const
3849

39-
function getItem(
40-
label: React.ReactNode,
41-
key: React.Key,
42-
icon?: React.ReactNode,
43-
children?: MenuItem[],
44-
): MenuItem {
45-
return {
46-
key,
47-
icon,
48-
children,
49-
label,
50-
}
50+
// antd's menu blues, kept as they were so the selected row survives the port unchanged
51+
const SELECTED_COLORS = {
52+
light: { backgroundColor: '#e6f4ff', color: '#1677ff' },
53+
dark: { backgroundColor: '#1668dc', color: '#fff' },
5154
}
5255

53-
function getGroup(label: React.ReactNode, key: React.Key, children: MenuItem[]): MenuItem {
56+
const ROW_HEIGHT = { compact: 36, roomy: 44 }
57+
/* WCAG 2.5.8 puts the floor for a pointer target at 24px. The sider is driven by a mouse
58+
so it can approach that; the drawer is touched, so it keeps a larger floor and lets the
59+
drawer scroll rather than shrink past it. */
60+
const MIN_ROW_HEIGHT = { compact: 28, roomy: 32 }
61+
62+
/**
63+
* Auto-fit: the list is a flex column sized to whatever holds it — the sider on desktop,
64+
* the drawer body on mobile — so the rows absorb the available height and shrink from
65+
* ROW_HEIGHT toward MIN_ROW_HEIGHT on a short viewport instead of overflowing. Past that
66+
* floor the container's own overflow scrolls.
67+
*
68+
* Every row is a flex item of this one container — the group headings are siblings rather
69+
* than nested lists — so all rows carry the same shrink weight and stay the same height as
70+
* each other. Spacing is `gap`, not margins: margins don't shrink, and would pin the rows
71+
* above their flex basis.
72+
*/
73+
const NavList = styled(List, {
74+
shouldForwardProp: (prop) => prop !== 'compact' && prop !== 'collapsed',
75+
})<MainMenuProps>(({ theme, compact, collapsed }) => {
76+
const density = compact ? 'compact' : 'roomy'
77+
5478
return {
55-
key,
56-
type: 'group',
57-
label,
58-
children,
79+
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
80+
display: 'flex',
81+
flexDirection: 'column',
82+
gap: 2,
83+
height: '100%',
84+
boxSizing: 'border-box',
85+
86+
'& .MuiListSubheader-root': {
87+
// Fixed overhead — only the rows shrink, so the headings stay lean: on a phone three
88+
// roomy headings cost more than a whole row of the height they're competing for.
89+
flex: '0 0 auto',
90+
padding: '6px 12px 2px',
91+
marginBlockStart: compact ? 4 : 8,
92+
backgroundColor: 'transparent',
93+
fontSize: 11,
94+
fontWeight: 700,
95+
lineHeight: 1.6,
96+
letterSpacing: '0.08em',
97+
textTransform: 'uppercase',
98+
color: theme.palette.text.secondary,
99+
},
100+
101+
'& .MuiListItem-root': {
102+
flex: `0 1 ${ROW_HEIGHT[density]}px`,
103+
minHeight: MIN_ROW_HEIGHT[density],
104+
},
105+
106+
'& .MuiListItemButton-root': {
107+
// The row's height comes from its flex item; the button only fills it.
108+
height: '100%',
109+
minHeight: 0,
110+
paddingBlock: 0,
111+
paddingInline: collapsed ? 0 : 12,
112+
justifyContent: collapsed ? 'center' : undefined,
113+
borderRadius: compact ? 8 : 10,
114+
'&.Mui-selected, &.Mui-selected:hover': SELECTED_COLORS[theme.palette.mode],
115+
},
116+
117+
'& .MuiListItemIcon-root': {
118+
minWidth: 0,
119+
marginInlineEnd: collapsed ? 0 : 10,
120+
color: 'inherit',
121+
'& .MuiSvgIcon-root': { fontSize: 20 },
122+
},
123+
124+
'& .MuiListItemText-primary': {
125+
fontSize: 14,
126+
},
59127
}
60-
}
128+
})
61129

62-
const MainMenu = ({ collapsed = false }: MainMenuProps) => {
63-
const { t } = useTranslation()
130+
const MainMenu = ({ collapsed = false, compact = false }: MainMenuProps) => {
131+
const { t, i18n } = useTranslation()
64132
const { setDrawerOpen } = useContext<LayoutContextInterface>(LayoutCtx)
65133
const [isDonateModalVisible, setDonateModalVisible] = useState(false)
134+
const { pathname } = useLocation()
135+
136+
// src/routes imports the layout, so PAGES is still in its temporal dead zone while
137+
// this module initializes — the lookup has to be built at render time.
138+
const pageByPath = new Map<string, (typeof PAGES)[number]>(PAGES.map((page) => [page.path, page]))
66139

67-
const handleDonateClick = (e: React.MouseEvent) => {
68-
e.preventDefault()
140+
const handleDonateClick = (event: React.MouseEvent) => {
141+
event.preventDefault()
69142
setDonateModalVisible(true)
70143
setDrawerOpen(false)
71144
}
72145

73-
const routeItems = PAGES.reduce<Record<string, MenuItem>>((acc, itm) => {
74-
acc[itm.path] =
75-
itm.label === 'donate_title'
76-
? getItem(
77-
<a href="#" onClick={handleDonateClick}>
78-
{t(itm.label)}
79-
</a>,
80-
itm.path,
81-
itm.icon,
82-
)
83-
: getItem(
84-
<Link to={itm.path} onClick={() => setDrawerOpen(false)}>
85-
{t(itm.label)}
86-
</Link>,
87-
itm.path,
88-
itm.icon,
89-
)
90-
return acc
91-
}, {})
92-
93-
const groupedItems: MenuItem[] = [
94-
routeItems['/'],
95-
...MENU_GROUPS.map(({ key, paths }) =>
96-
getGroup(
97-
<span className="sidebar-menu-group-title">{t(key)}</span>,
98-
key,
99-
paths.map((path) => routeItems[path]).filter(Boolean),
100-
),
101-
),
102-
].filter(Boolean)
103-
104-
const flatItems: MenuItem[] = [
105-
routeItems['/'],
106-
...MENU_GROUPS.flatMap(({ paths }) => paths.map((path) => routeItems[path]).filter(Boolean)),
107-
].filter(Boolean)
108-
109-
const items = collapsed ? flatItems : groupedItems
146+
const renderItem = (path: string) => {
147+
const page = pageByPath.get(path)
148+
if (!page) return null
110149

111-
const { pathname } = useLocation()
112-
const [current, setCurrent] = useState(pathname || '/')
113-
114-
useEffect(() => {
115-
const nextPath = pathname || '/'
150+
const label = t(page.label)
151+
const selected = pathname === path
152+
const ariaCurrent = selected ? 'page' : undefined
153+
const content = (
154+
<>
155+
<ListItemIcon>{page.icon}</ListItemIcon>
156+
{!collapsed && <ListItemText primary={label} slotProps={{ primary: { noWrap: true } }} />}
157+
</>
158+
)
116159

117-
if (current !== nextPath) {
118-
setCurrent(nextPath)
119-
}
120-
}, [pathname, current])
160+
const button =
161+
page.label === 'donate_title' ? (
162+
<ListItemButton
163+
component="a"
164+
href="#"
165+
onClick={handleDonateClick}
166+
selected={selected}
167+
aria-current={ariaCurrent}>
168+
{content}
169+
</ListItemButton>
170+
) : (
171+
<ListItemButton
172+
component={Link}
173+
to={path}
174+
onClick={() => setDrawerOpen(false)}
175+
selected={selected}
176+
aria-current={ariaCurrent}>
177+
{content}
178+
</ListItemButton>
179+
)
121180

122-
const handleClick: MenuProps['onClick'] = ({ key }) => {
123-
setCurrent(key)
181+
return (
182+
<ListItem key={path} disablePadding>
183+
{collapsed ? (
184+
<Tooltip title={label} placement={i18n.dir() === 'rtl' ? 'left' : 'right'}>
185+
{button}
186+
</Tooltip>
187+
) : (
188+
button
189+
)}
190+
</ListItem>
191+
)
124192
}
193+
125194
return (
126195
<>
127-
<Menu
128-
className="sidebar-menu"
129-
onClick={handleClick}
130-
theme="light"
131-
selectedKeys={[current]}
132-
mode="inline"
133-
inlineCollapsed={collapsed}
134-
items={items}
135-
/>
196+
<NavList className="sidebar-menu" compact={compact} collapsed={collapsed}>
197+
{renderItem('/')}
198+
{MENU_GROUPS.flatMap(({ key, paths }) => [
199+
collapsed ? null : (
200+
<ListSubheader key={key} disableSticky>
201+
{t(key)}
202+
</ListSubheader>
203+
),
204+
...paths.map((path) => renderItem(path)),
205+
])}
206+
</NavList>
136207
<DonateModal isVisible={isDonateModalVisible} onClose={() => setDonateModalVisible(false)} />
137208
</>
138209
)

src/layout/sidebar/menu/menu.scss

Lines changed: 0 additions & 66 deletions
This file was deleted.

src/layout/sidebar/sidebar.scss

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
.sidebar-section {
2-
display: flex;
3-
flex-direction: column;
4-
}
5-
61
.ant-layout-sider {
72
position: relative;
83
z-index: 1;
@@ -20,12 +15,3 @@
2015
justify-content: end;
2116
}
2217
}
23-
24-
.dark .ant-menu {
25-
background-color: transparent;
26-
27-
.ant-menu-item-selected {
28-
color: white !important;
29-
background-color: #1668dc !important;
30-
}
31-
}

0 commit comments

Comments
 (0)