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
221 changes: 180 additions & 41 deletions src/layout/sidebar/menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { DatasetTwoTone, GroupsTwoTone } from '@mui/icons-material'
import {
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Tab,
Tabs,
Tooltip,
} from '@mui/material'
import { styled } from '@mui/material/styles'
import type { ParseKeys } from 'i18next'
import React, { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
Expand All @@ -24,33 +28,71 @@ type MainMenuProps = {
compact?: boolean
}

const MENU_GROUPS = [
type MenuSection = {
key: ParseKeys
icon: React.ReactElement
groups: { key?: ParseKeys; paths: string[] }[]
}

/* The nav splits along the site's two halves: the data you came to look at, and the
people who collect it. They aren't peers — data pages are working surfaces you move
between mid-task, community pages are read-once destinations — so tabbing them keeps
the working set short while promoting "about"/"donate" from a subheading buried
two-thirds down the list to one of two always-visible labels. */
const MENU_SECTIONS: MenuSection[] = [
{
key: 'menu_group_analysis',
paths: [
'/single-line-map',
'/timeline',
'/gaps',
'/gaps_patterns',
'/operator',
'/vehicle',
'/train',
key: 'menu_section_data',
icon: <DatasetTwoTone />,
groups: [
{
key: 'menu_group_analysis',
paths: [
'/single-line-map',
'/timeline',
'/gaps',
'/gaps_patterns',
'/operator',
'/vehicle',
'/train',
],
},
{ key: 'menu_group_maps', paths: ['/map', '/velocity-heatmap'] },
],
},
{
key: 'menu_group_maps',
paths: ['/map', '/velocity-heatmap'],
key: 'menu_section_community',
icon: <GroupsTwoTone />,
// One unnamed group: inside a tab already labelled "community", a "community"
// subheader is noise.
groups: [{ paths: ['/public-appeal', '/about', '/donate'] }],
},
{
key: 'menu_group_community',
paths: ['/public-appeal', '/about', '/donate'],
},
] as const
]

const sectionOfPath = (pathname: string): ParseKeys | undefined =>
MENU_SECTIONS.find((section) => section.groups.some((group) => group.paths.includes(pathname)))
?.key

// antd's menu blues, kept as they were so the selected row survives the port unchanged
/* antd's menu blues, rebalanced so the two modes carry the same weight: they used to be a
near-white tint with blue text in light (too faint) against a solid fill with white text
in dark (too loud, and it rivalled the section chip). Both are now a tinted ground with
saturated text, which keeps the solid chip above them in the hierarchy either way. */
const SELECTED_COLORS = {
light: { backgroundColor: '#e6f4ff', color: '#1677ff' },
dark: { backgroundColor: '#1668dc', color: '#fff' },
light: { backgroundColor: '#bae0ff', color: '#0958d9' },
dark: { backgroundColor: 'rgba(22,119,255,0.26)', color: '#69b1ff' },
}

/* The segmented control, keyed by mode like SELECTED_COLORS above.

The chip carries the brand blue on purpose: the strip outranks the list it controls, so
a neutral grey chip left it quieter than the selected row below and the whole strip got
overlooked. What keeps it from reading as just another row is the track — a recessed
well, which no row ever sits in — not the absence of colour.

Track is a black alpha rather than a fixed grey so the well works against whichever
background the sider happens to have. */
const SEGMENTED = {
light: { track: 'rgba(0,0,0,0.10)', chip: '#1677ff', chipShadow: '0 3px 6px rgba(0,0,0,0.28)' },
dark: { track: 'rgba(0,0,0,0.40)', chip: '#1677ff', chipShadow: '0 3px 6px rgba(0,0,0,0.6)' },
}

const ROW_HEIGHT = { compact: 36, roomy: 44 }
Expand All @@ -59,29 +101,96 @@ const ROW_HEIGHT = { compact: 36, roomy: 44 }
drawer scroll rather than shrink past it. */
const MIN_ROW_HEIGHT = { compact: 28, roomy: 32 }

/* Owns the padding and splits the height: the tab strip is fixed overhead (like the
subheaders), and the section list takes whatever is left. */
const MenuShell = styled('div', {
shouldForwardProp: (prop) => prop !== 'compact',
})<Pick<MainMenuProps, 'compact'>>(({ compact }) => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
boxSizing: 'border-box',
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
}))

/* A segmented control, not an underline: the recessed track is what advertises that there
is a second option at all — an underline only marks the active one, leaving the other as
inert-looking text that is easy to miss. The indicator is stretched to fill its slot so
it reads as a raised chip sliding between two visible slots.

It must also not borrow the rows' selected colours: a tab picks which list you are
looking at, a row picks which page is open, and giving both the same blue fill made the
strip read as two more menu rows. */
const SectionTabs = styled(Tabs, {
shouldForwardProp: (prop) => prop !== 'compact',
})<Pick<MainMenuProps, 'compact'>>(({ theme, compact }) => ({
flex: '0 0 auto',
minHeight: 0,
marginBottom: compact ? 6 : 10,
padding: 3,
borderRadius: 10,
backgroundColor: SEGMENTED[theme.palette.mode].track,
/* The chip is exactly the scroller's box, so every edge of its shadow that touches the
scroller gets clipped — all of them but the one facing the strip's centre, which is
what made the shadow look half-drawn. `!important` is needed because Tabs sets
`overflow: hidden` as an INLINE style on the scroller (Tabs.js: `overflow:
scrollerStyle.overflow`); a class rule cannot beat it. Safe here: with two fullWidth
tabs there is nothing to scroll. */
overflow: 'visible',
'& .MuiTabs-scroller': { overflow: 'visible !important' },
'& .MuiTabs-indicator': {
height: '100%',
borderRadius: 7,
backgroundColor: SEGMENTED[theme.palette.mode].chip,
boxShadow: SEGMENTED[theme.palette.mode].chipShadow,
zIndex: 0,
},
'& .MuiTab-root': {
// the indicator is a later sibling, so without this it paints over the label
position: 'relative',
zIndex: 1,
flex: 1,
minWidth: 0,
minHeight: compact ? 32 : 36,
padding: '4px 6px',
gap: 6,
// the press/hover fill is clipped to the tab's own box, so without this it lands as a
// sharp-cornered rectangle inside the rounded track
borderRadius: 7,
fontSize: 13,
fontWeight: 600,
textTransform: 'none',
color: theme.palette.text.secondary,
'& .MuiSvgIcon-root': { fontSize: 18 },
'&.Mui-selected': { color: '#fff' },
},
}))

/**
* 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.
* Auto-fit: the section list is a flex column sized to whatever the shell leaves it, 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 list scrolls, which keeps
* the tab strip pinned above it.
*
* 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.
* Every row is a flex item of 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 {
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
boxSizing: 'border-box',
flex: '1 1 auto',
minHeight: 0,
overflow: 'auto',

'& .MuiListSubheader-root': {
// Fixed overhead — only the rows shrink, so the headings stay lean: on a phone three
Expand Down Expand Up @@ -133,6 +242,21 @@ const MainMenu = ({ collapsed = false, compact = false }: MainMenuProps) => {
const [isDonateModalVisible, setDonateModalVisible] = useState(false)
const { pathname } = useLocation()

const [sectionKey, setSectionKey] = useState(
() => sectionOfPath(pathname) ?? MENU_SECTIONS[0].key,
)
/* The tab tracks the URL, but is switchable on its own too — you can browse the
community list while a data page stays open — so it can't be a plain derivation of
pathname. Adjusting during render rather than in an effect keeps a cross-section
navigation (a link in the page body to /about) from painting the old tab for a frame. */
const [lastPathname, setLastPathname] = useState(pathname)
if (pathname !== lastPathname) {
setLastPathname(pathname)
const next = sectionOfPath(pathname)
if (next && next !== sectionKey) setSectionKey(next)
}
const section = MENU_SECTIONS.find(({ key }) => key === sectionKey) ?? MENU_SECTIONS[0]

// 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]))
Expand Down Expand Up @@ -193,17 +317,32 @@ const MainMenu = ({ collapsed = false, compact = false }: MainMenuProps) => {

return (
<>
<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>
<MenuShell className="sidebar-menu" compact={compact}>
<SectionTabs
value={section.key}
onChange={(_, value: ParseKeys) => setSectionKey(value)}
compact={compact}
variant="fullWidth"
aria-label={t('menu_sections_label')}>
{MENU_SECTIONS.map(({ key, icon }) =>
collapsed ? (
<Tab key={key} value={key} icon={icon} aria-label={t(key)} title={t(key)} />
) : (
<Tab key={key} value={key} icon={icon} iconPosition="start" label={t(key)} />
),
)}
</SectionTabs>
<NavList compact={compact} collapsed={collapsed}>
{section.groups.flatMap(({ key, paths }) => [
key && !collapsed ? (
<ListSubheader key={key} disableSticky>
{t(key)}
</ListSubheader>
) : null,
...paths.map((path) => renderItem(path)),
])}
</NavList>
</MenuShell>
<DonateModal isVisible={isDonateModalVisible} onClose={() => setDonateModalVisible(false)} />
</>
)
Expand Down
2 changes: 1 addition & 1 deletion src/locale/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
"menu_group_get_started": "البداية",
"menu_group_analysis": "تحليل الرحلات",
"menu_group_maps": "الخرائط",
"menu_group_community": "المجتمع",
"menu_section_community": "المجتمع",
"report_a_bug_title": "الإبلاغ عن خطأ",
"how_to_donate_title": "كيف أتبرّع؟",
"how_to_donate_text": "خلف الكواليس يعمل فريق صغير ومتفانٍ، يهتمّ بتنظيم لقاءات التطوير والهاكاثونات وصيانة السيرڤرات، يتنقّل بين المكاتب الحكومية للإقناع والضغط والمساعدة على إصدار المزيد من قواعد البيانات؛ يتأكّد من أنّ الإعلام يعرف عملنا ويعرف كيف يتواصل معنا للحصول على البيانات والتحليلات، ويقوم بالكثير من العمل الجاري حتّى تُحدِث مشاريعنا تغييراً في العالم. نحن بحاجة لدعم المجتمع للاستمرار في العمل من أجل حكومة أكثر انفتاحاً. كونوا فرسان الشفافية وانضمّوا للدعم الشهري.",
Expand Down
4 changes: 3 additions & 1 deletion src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@
"menu_group_get_started": "Get Started",
"menu_group_analysis": "Trip Analysis",
"menu_group_maps": "Maps",
"menu_group_community": "Community",
"menu_section_data": "Data",
"menu_section_community": "Community",
"menu_sections_label": "Menu sections",
"report_a_bug_title": "Report a bug",
"how_to_donate_title": "How can I donate?",
"how_to_donate_text": "Behind the scenes of the projects is a small, dedicated team that takes care of organizing development and hackathon meetings and server maintenance, which has its feet between government offices to persuade, press, and swing to release more and more repositories; Make sure the media knows our work and also knows how to contact us for data and analytics, and does a lot of ongoing work to make our projects change in the world. We need community support to continue to work for a more open government. There were knights of transparency and joined in monthly support.",
Expand Down
4 changes: 3 additions & 1 deletion src/locale/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@
"menu_group_get_started": "התחלה",
"menu_group_analysis": "ניתוח נסיעות",
"menu_group_maps": "מפות",
"menu_group_community": "קהילה",
"menu_section_data": "נתונים",
"menu_section_community": "קהילה",
"menu_sections_label": "מדורי התפריט",
"report_a_bug_title": "דיווח על באג",
"how_to_donate_title": "כיצד לתרום?",
"how_to_donate_text": "מאחורי הקלעים של הפרוייקטים פועל צוות קטן ומסור, שדואג לארגון מפגשי הפיתוח וההאקתונים ותחזוקת השרתים, שמכתת רגליו בין משרדי ממשלה כדי לשכנע, ללחוץ, ולנדנד לשחרר עוד ועוד מאגרים; מוודא שהתקשורת תכיר את העבודה שלנו וגם תדע לפנות אלינו בשביל נתונים וניתוחים, ועושה עוד המון עבודה שוטפת כדי שהפרוייקטים שלנו ייצרו שינוי בעולם. אנחנו זקוקים לתמיכה של הקהילה כדי להמשיך ולפעול למען ממשל פתוח יותר. היו אבירי שקיפות והצטרפו בתמיכה חודשית.",
Expand Down
2 changes: 1 addition & 1 deletion src/locale/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
"menu_group_get_started": "Начало",
"menu_group_analysis": "Анализ поездок",
"menu_group_maps": "Карты",
"menu_group_community": "Сообщество",
"menu_section_community": "Сообщество",
"report_a_bug_title": "Сообщить об ошибке",
"how_to_donate_title": "Как я могу пожертвовать?",
"how_to_donate_text": "За кулисами проектов стоит небольшая, преданная команда, которая заботится об организации встреч по разработке и хакатонов и обслуживании серверов, которая ставит ноги между правительственными офисами, чтобы убеждать, давить и раскачивать для выпуска все больше и больше репозиториев; Убедиться, что СМИ знают нашу работу, а также знают, как связаться с нами для получения данных и аналитики, и делает много постоянной работы, чтобы наши проекты изменились в мире. Нам нужна поддержка сообщества, чтобы продолжать работать для более открытого правительства. Были рыцари прозрачности и присоединились к ежемесячной поддержке.",
Expand Down
46 changes: 40 additions & 6 deletions tests/menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
import { Page } from '@playwright/test'
import { expect, setupTest, test } from './utils'

const MENU_ITEMS = [
'ראשי',
const DATA_MENU_ITEMS = [
'מסלול נסיעה',
'היסטוריית נסיעות',
'נסיעות שלא בוצעו',
'דפוסי נסיעות שלא בוצעו',
'חברה מפעילה',
'רכב',
'רכבת ישראל',
'מפת תחבורה',
'מפת מהירות',
'קול קורא',
'אודות',
'לתרומות',
]

const COMMUNITY_MENU_ITEMS = ['קול קורא', 'אודות', 'לתרומות']

const menuLinks = (page: Page) => page.locator('.sidebar-menu:visible').locator('ul > li a')

test.beforeEach(async ({ page }) => {
await setupTest(page)
})

test('should display logo and menu items correctly', async ({ page }) => {
await expect(page.locator('.main-header .header-logo')).toHaveAccessibleName('דאטאבוס')
await expect(page.locator('ul > li a')).toContainText(MENU_ITEMS)
await expect(menuLinks(page)).toContainText(DATA_MENU_ITEMS)
})

// The menu carries no "home" row — the header logo is the way back. If that ever stops
// being a link home, the sidebar has to grow the row back.
test('the logo is the way back to the homepage', async ({ page }) => {
await page.getByRole('tab', { name: 'קהילה' }).click()
await page.getByRole('link', { name: 'אודות', exact: true }).click()
await expect(page).toHaveURL(/\/about/)
await page.locator('.main-header .header-logo').click()
await expect(page).toHaveURL((url) => url.pathname === '/')
})

test('the community tab swaps the list', async ({ page }) => {
await page.getByRole('tab', { name: 'קהילה' }).click()
await expect(menuLinks(page)).toContainText(COMMUNITY_MENU_ITEMS)
})

test('the tab follows the open page', async ({ page }) => {
await page.getByRole('tab', { name: 'קהילה' }).click()
await page.getByRole('link', { name: 'אודות', exact: true }).click()
await expect(page).toHaveURL(/\/about/)
// Landing on a community page must leave the community tab selected, or the menu
// would contradict the URL.
await expect(page.getByRole('tab', { name: 'קהילה' })).toHaveAttribute('aria-selected', 'true')
})

test('a deep link to a community page opens on the community tab', async ({ page }) => {
await page.goto('/about')
await page.locator('.preloader').waitFor({ state: 'hidden' })
await expect(page.getByRole('tab', { name: 'קהילה' })).toHaveAttribute('aria-selected', 'true')
await expect(menuLinks(page)).toContainText(COMMUNITY_MENU_ITEMS)
})

test("the main header doesn't show duplicate icons", async ({ page }) => {
Expand Down
Loading
Loading