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'
412import { useTranslation } from 'react-i18next'
513import { Link , useLocation } from 'react-router'
614import { LayoutContextInterface , LayoutCtx } from 'src/layout/LayoutContext'
715import DonateModal from 'src/pages/DonateModal/DonateModal'
816import { PAGES } from 'src/routes'
9- import './menu.scss'
1017
11- type MenuItem = Required < MenuProps > [ 'items' ] [ number ]
1218type 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
1627const 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 )
0 commit comments