Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/layout/sidebar/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ export default function SideBar() {
collapsible
collapsed={collapsed}
style={{
marginBottom: '48px',
// No bottom margin for the fixed trigger: antd already reserves its 48px as
// padding on the sider, and reserving it twice cost the menu a row of height.
boxShadow: isDarkTheme ? '0 0 12px 4px rgba(0,0,0,0.7)' : '0 0 12px 4px rgba(0,0,0,0.12)',
}}
onCollapse={setCollapsed}
className={cn('hideOnMobile', { dark: isDarkTheme })}>
<Menu collapsed={collapsed} />
<Menu collapsed={collapsed} compact />
</Sider>
</>
)
Expand Down
239 changes: 155 additions & 84 deletions src/layout/sidebar/menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import type { MenuProps } from 'antd'
import { Menu } from 'antd'
import React, { useContext, useEffect, useState } from 'react'
import {
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Tooltip,
} from '@mui/material'
import { styled } from '@mui/material/styles'
import React, { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
import { LayoutContextInterface, LayoutCtx } from 'src/layout/LayoutContext'
import DonateModal from 'src/pages/DonateModal/DonateModal'
import { PAGES } from 'src/routes'
import './menu.scss'

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

const MENU_GROUPS = [
Expand All @@ -36,103 +47,163 @@ const MENU_GROUPS = [
},
] as const

function getItem(
label: React.ReactNode,
key: React.Key,
icon?: React.ReactNode,
children?: MenuItem[],
): MenuItem {
return {
key,
icon,
children,
label,
}
// antd's menu blues, kept as they were so the selected row survives the port unchanged
const SELECTED_COLORS = {
light: { backgroundColor: '#e6f4ff', color: '#1677ff' },
dark: { backgroundColor: '#1668dc', color: '#fff' },
}

function getGroup(label: React.ReactNode, key: React.Key, children: MenuItem[]): MenuItem {
const ROW_HEIGHT = { compact: 36, roomy: 44 }
/* WCAG 2.5.8 puts the floor for a pointer target at 24px. The sider is driven by a mouse
so it can approach that; the drawer is touched, so it keeps a larger floor and lets the
drawer scroll rather than shrink past it. */
const MIN_ROW_HEIGHT = { compact: 28, roomy: 32 }

/**
* Auto-fit: the list is a flex column sized to whatever holds it — the sider on desktop,
* the drawer body on mobile — so the rows absorb the available height and shrink from
* ROW_HEIGHT toward MIN_ROW_HEIGHT on a short viewport instead of overflowing. Past that
* floor the container's own overflow scrolls.
*
* Every row is a flex item of this one container — the group headings are siblings rather
* than nested lists — so all rows carry the same shrink weight and stay the same height as
* each other. Spacing is `gap`, not margins: margins don't shrink, and would pin the rows
* above their flex basis.
*/
const NavList = styled(List, {
shouldForwardProp: (prop) => prop !== 'compact' && prop !== 'collapsed',
})<MainMenuProps>(({ theme, compact, collapsed }) => {
const density = compact ? 'compact' : 'roomy'

return {
key,
type: 'group',
label,
children,
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
boxSizing: 'border-box',

'& .MuiListSubheader-root': {
// Fixed overhead — only the rows shrink, so the headings stay lean: on a phone three
// roomy headings cost more than a whole row of the height they're competing for.
flex: '0 0 auto',
padding: '6px 12px 2px',
marginBlockStart: compact ? 4 : 8,
backgroundColor: 'transparent',
fontSize: 11,
fontWeight: 700,
lineHeight: 1.6,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: theme.palette.text.secondary,
},

'& .MuiListItem-root': {
flex: `0 1 ${ROW_HEIGHT[density]}px`,
minHeight: MIN_ROW_HEIGHT[density],
},

'& .MuiListItemButton-root': {
// The row's height comes from its flex item; the button only fills it.
height: '100%',
minHeight: 0,
paddingBlock: 0,
paddingInline: collapsed ? 0 : 12,
justifyContent: collapsed ? 'center' : undefined,
borderRadius: compact ? 8 : 10,
'&.Mui-selected, &.Mui-selected:hover': SELECTED_COLORS[theme.palette.mode],
},

'& .MuiListItemIcon-root': {
minWidth: 0,
marginInlineEnd: collapsed ? 0 : 10,
color: 'inherit',
'& .MuiSvgIcon-root': { fontSize: 20 },
},

'& .MuiListItemText-primary': {
fontSize: 14,
},
}
}
})

const MainMenu = ({ collapsed = false }: MainMenuProps) => {
const { t } = useTranslation()
const MainMenu = ({ collapsed = false, compact = false }: MainMenuProps) => {
const { t, i18n } = useTranslation()
const { setDrawerOpen } = useContext<LayoutContextInterface>(LayoutCtx)
const [isDonateModalVisible, setDonateModalVisible] = useState(false)
const { pathname } = useLocation()

// src/routes imports the layout, so PAGES is still in its temporal dead zone while
// this module initializes — the lookup has to be built at render time.
const pageByPath = new Map<string, (typeof PAGES)[number]>(PAGES.map((page) => [page.path, page]))

const handleDonateClick = (e: React.MouseEvent) => {
e.preventDefault()
const handleDonateClick = (event: React.MouseEvent) => {
event.preventDefault()
setDonateModalVisible(true)
setDrawerOpen(false)
}

const routeItems = PAGES.reduce<Record<string, MenuItem>>((acc, itm) => {
acc[itm.path] =
itm.label === 'donate_title'
? getItem(
<a href="#" onClick={handleDonateClick}>
{t(itm.label)}
</a>,
itm.path,
itm.icon,
)
: getItem(
<Link to={itm.path} onClick={() => setDrawerOpen(false)}>
{t(itm.label)}
</Link>,
itm.path,
itm.icon,
)
return acc
}, {})

const groupedItems: MenuItem[] = [
routeItems['/'],
...MENU_GROUPS.map(({ key, paths }) =>
getGroup(
<span className="sidebar-menu-group-title">{t(key)}</span>,
key,
paths.map((path) => routeItems[path]).filter(Boolean),
),
),
].filter(Boolean)

const flatItems: MenuItem[] = [
routeItems['/'],
...MENU_GROUPS.flatMap(({ paths }) => paths.map((path) => routeItems[path]).filter(Boolean)),
].filter(Boolean)

const items = collapsed ? flatItems : groupedItems
const renderItem = (path: string) => {
const page = pageByPath.get(path)
if (!page) return null

const { pathname } = useLocation()
const [current, setCurrent] = useState(pathname || '/')

useEffect(() => {
const nextPath = pathname || '/'
const label = t(page.label)
const selected = pathname === path
const ariaCurrent = selected ? 'page' : undefined
const content = (
<>
<ListItemIcon>{page.icon}</ListItemIcon>
{!collapsed && <ListItemText primary={label} slotProps={{ primary: { noWrap: true } }} />}
</>
)

if (current !== nextPath) {
setCurrent(nextPath)
}
}, [pathname, current])
const button =
page.label === 'donate_title' ? (
<ListItemButton
component="a"
href="#"
onClick={handleDonateClick}
selected={selected}
aria-current={ariaCurrent}>
{content}
</ListItemButton>
) : (
<ListItemButton
component={Link}
to={path}
onClick={() => setDrawerOpen(false)}
selected={selected}
aria-current={ariaCurrent}>
{content}
</ListItemButton>
)

const handleClick: MenuProps['onClick'] = ({ key }) => {
setCurrent(key)
return (
<ListItem key={path} disablePadding>
{collapsed ? (
<Tooltip title={label} placement={i18n.dir() === 'rtl' ? 'left' : 'right'}>
{button}
</Tooltip>
) : (
button
)}
</ListItem>
)
}

return (
<>
<Menu
className="sidebar-menu"
onClick={handleClick}
theme="light"
selectedKeys={[current]}
mode="inline"
inlineCollapsed={collapsed}
items={items}
/>
<NavList className="sidebar-menu" compact={compact} collapsed={collapsed}>
{renderItem('/')}
{MENU_GROUPS.flatMap(({ key, paths }) => [
collapsed ? null : (
<ListSubheader key={key} disableSticky>
{t(key)}
</ListSubheader>
),
...paths.map((path) => renderItem(path)),
])}
</NavList>
<DonateModal isVisible={isDonateModalVisible} onClose={() => setDonateModalVisible(false)} />
</>
)
Expand Down
66 changes: 0 additions & 66 deletions src/layout/sidebar/menu/menu.scss

This file was deleted.

9 changes: 0 additions & 9 deletions src/layout/sidebar/sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,3 @@
justify-content: end;
}
}

.dark .ant-menu {
background-color: transparent;

.ant-menu-item-selected {
color: white !important;
background-color: #1668dc !important;
}
}
2 changes: 1 addition & 1 deletion src/pages/gapsPatterns/GapsPatternsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ const GapsPatternsPage = () => {

return (
<PageContainer>
<Typography variant="h4">
<Typography className="page-title" variant="h4">
{t('gaps_patterns_page_title')}
<InfoYoutubeModal
label={t('open_video_about_this_page')}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/homepage/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const HomePage = () => {
return (
<div className="container">
<BusImage role="img" aria-label={t('homepage.bus_illustration_alt')} />
<h1>{t('homepage.welcome')}</h1>
<h1 className="page-title">{t('homepage.welcome')}</h1>
<h2>{t('homepage.databus_definition')}</h2>
<p>{t('homepage.website_goal')}</p>

Expand Down
Loading
Loading