Skip to content

Commit b2e0605

Browse files
committed
initial
1 parent 4be4048 commit b2e0605

7 files changed

Lines changed: 199 additions & 48 deletions

File tree

src/layout/sidebar/menu/Menu.tsx

Lines changed: 134 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import { DatasetTwoTone, GroupsTwoTone } from '@mui/icons-material'
12
import {
23
List,
34
ListItem,
45
ListItemButton,
56
ListItemIcon,
67
ListItemText,
78
ListSubheader,
9+
Tab,
10+
Tabs,
811
Tooltip,
912
} from '@mui/material'
1013
import { styled } from '@mui/material/styles'
14+
import type { ParseKeys } from 'i18next'
1115
import React, { useContext, useState } from 'react'
1216
import { useTranslation } from 'react-i18next'
1317
import { Link, useLocation } from 'react-router'
@@ -24,28 +28,49 @@ type MainMenuProps = {
2428
compact?: boolean
2529
}
2630

27-
const MENU_GROUPS = [
31+
type MenuSection = {
32+
key: ParseKeys
33+
icon: React.ReactElement
34+
groups: { key?: ParseKeys; paths: string[] }[]
35+
}
36+
37+
/* The nav splits along the site's two halves: the data you came to look at, and the
38+
people who collect it. They aren't peers — data pages are working surfaces you move
39+
between mid-task, community pages are read-once destinations — so tabbing them keeps
40+
the working set short while promoting "about"/"donate" from a subheading buried
41+
two-thirds down the list to one of two always-visible labels. */
42+
const MENU_SECTIONS: MenuSection[] = [
2843
{
29-
key: 'menu_group_analysis',
30-
paths: [
31-
'/single-line-map',
32-
'/timeline',
33-
'/gaps',
34-
'/gaps_patterns',
35-
'/operator',
36-
'/vehicle',
37-
'/train',
44+
key: 'menu_section_data',
45+
icon: <DatasetTwoTone />,
46+
groups: [
47+
{
48+
key: 'menu_group_analysis',
49+
paths: [
50+
'/single-line-map',
51+
'/timeline',
52+
'/gaps',
53+
'/gaps_patterns',
54+
'/operator',
55+
'/vehicle',
56+
'/train',
57+
],
58+
},
59+
{ key: 'menu_group_maps', paths: ['/map', '/velocity-heatmap'] },
3860
],
3961
},
4062
{
41-
key: 'menu_group_maps',
42-
paths: ['/map', '/velocity-heatmap'],
43-
},
44-
{
45-
key: 'menu_group_community',
46-
paths: ['/public-appeal', '/about', '/donate'],
63+
key: 'menu_section_community',
64+
icon: <GroupsTwoTone />,
65+
// One unnamed group: inside a tab already labelled "community", a "community"
66+
// subheader is noise.
67+
groups: [{ paths: ['/public-appeal', '/about', '/donate'] }],
4768
},
48-
] as const
69+
]
70+
71+
const sectionOfPath = (pathname: string): ParseKeys | undefined =>
72+
MENU_SECTIONS.find((section) => section.groups.some((group) => group.paths.includes(pathname)))
73+
?.key
4974

5075
// antd's menu blues, kept as they were so the selected row survives the port unchanged
5176
const SELECTED_COLORS = {
@@ -59,29 +84,70 @@ const ROW_HEIGHT = { compact: 36, roomy: 44 }
5984
drawer scroll rather than shrink past it. */
6085
const MIN_ROW_HEIGHT = { compact: 28, roomy: 32 }
6186

87+
/* Owns the padding and splits the height: the tab strip is fixed overhead (like the
88+
subheaders), and the section list takes whatever is left. */
89+
const MenuShell = styled('div', {
90+
shouldForwardProp: (prop) => prop !== 'compact',
91+
})<Pick<MainMenuProps, 'compact'>>(({ compact }) => ({
92+
display: 'flex',
93+
flexDirection: 'column',
94+
height: '100%',
95+
boxSizing: 'border-box',
96+
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
97+
}))
98+
99+
/* Deliberately NOT the rows' selected treatment: a tab picks which list you are looking
100+
at, a row picks which page is open. Giving both the same filled pill made the strip read
101+
as two more menu rows, so the tabs take the conventional underline instead, over a rule
102+
that reads as the top edge of the list they control. The accent is the theme's primary
103+
rather than the rows' antd blue, which keeps the two states visibly different. */
104+
const SectionTabs = styled(Tabs, {
105+
shouldForwardProp: (prop) => prop !== 'compact',
106+
})<Pick<MainMenuProps, 'compact'>>(({ theme, compact }) => ({
107+
flex: '0 0 auto',
108+
minHeight: 0,
109+
marginBottom: compact ? 6 : 8,
110+
borderBottom: `1px solid ${theme.palette.divider}`,
111+
'& .MuiTabs-indicator': { height: 2 },
112+
'& .MuiTab-root': {
113+
flex: 1,
114+
minWidth: 0,
115+
minHeight: compact ? 34 : 40,
116+
padding: '4px 8px',
117+
fontSize: 13,
118+
fontWeight: 500,
119+
textTransform: 'none',
120+
color: theme.palette.text.secondary,
121+
'& .MuiSvgIcon-root': { fontSize: 20 },
122+
'&.Mui-selected': { color: theme.palette.primary.main, fontWeight: 700 },
123+
},
124+
}))
125+
62126
/**
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.
127+
* Auto-fit: the section list is a flex column sized to whatever the shell leaves it, so
128+
* the rows absorb the available height and shrink from ROW_HEIGHT toward MIN_ROW_HEIGHT on
129+
* a short viewport instead of overflowing. Past that floor the list scrolls, which keeps
130+
* the tab strip pinned above it.
67131
*
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.
132+
* Every row is a flex item of one container — the group headings are siblings rather than
133+
* nested lists — so all rows carry the same shrink weight and stay the same height as each
134+
* other. Spacing is `gap`, not margins: margins don't shrink, and would pin the rows above
135+
* their flex basis.
72136
*/
73137
const NavList = styled(List, {
74138
shouldForwardProp: (prop) => prop !== 'compact' && prop !== 'collapsed',
75139
})<MainMenuProps>(({ theme, compact, collapsed }) => {
76140
const density = compact ? 'compact' : 'roomy'
77141

78142
return {
79-
padding: compact ? '4px 8px 8px' : '8px 10px 12px',
143+
padding: 0,
80144
display: 'flex',
81145
flexDirection: 'column',
82146
gap: 2,
83-
height: '100%',
84147
boxSizing: 'border-box',
148+
flex: '1 1 auto',
149+
minHeight: 0,
150+
overflow: 'auto',
85151

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

202+
const [sectionKey, setSectionKey] = useState(
203+
() => sectionOfPath(pathname) ?? MENU_SECTIONS[0].key,
204+
)
205+
/* The tab tracks the URL, but is switchable on its own too — you can browse the
206+
community list while a data page stays open — so it can't be a plain derivation of
207+
pathname. Adjusting during render rather than in an effect keeps a cross-section
208+
navigation (a link in the page body to /about) from painting the old tab for a frame. */
209+
const [lastPathname, setLastPathname] = useState(pathname)
210+
if (pathname !== lastPathname) {
211+
setLastPathname(pathname)
212+
const next = sectionOfPath(pathname)
213+
if (next && next !== sectionKey) setSectionKey(next)
214+
}
215+
const section = MENU_SECTIONS.find(({ key }) => key === sectionKey) ?? MENU_SECTIONS[0]
216+
136217
// src/routes imports the layout, so PAGES is still in its temporal dead zone while
137218
// this module initializes — the lookup has to be built at render time.
138219
const pageByPath = new Map<string, (typeof PAGES)[number]>(PAGES.map((page) => [page.path, page]))
@@ -193,17 +274,32 @@ const MainMenu = ({ collapsed = false, compact = false }: MainMenuProps) => {
193274

194275
return (
195276
<>
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>
277+
<MenuShell className="sidebar-menu" compact={compact}>
278+
<SectionTabs
279+
value={section.key}
280+
onChange={(_, value: ParseKeys) => setSectionKey(value)}
281+
compact={compact}
282+
variant="fullWidth"
283+
aria-label={t('menu_sections_label')}>
284+
{MENU_SECTIONS.map(({ key, icon }) =>
285+
collapsed ? (
286+
<Tab key={key} value={key} icon={icon} aria-label={t(key)} title={t(key)} />
287+
) : (
288+
<Tab key={key} value={key} label={t(key)} />
289+
),
290+
)}
291+
</SectionTabs>
292+
<NavList compact={compact} collapsed={collapsed}>
293+
{section.groups.flatMap(({ key, paths }) => [
294+
key && !collapsed ? (
295+
<ListSubheader key={key} disableSticky>
296+
{t(key)}
297+
</ListSubheader>
298+
) : null,
299+
...paths.map((path) => renderItem(path)),
300+
])}
301+
</NavList>
302+
</MenuShell>
207303
<DonateModal isVisible={isDonateModalVisible} onClose={() => setDonateModalVisible(false)} />
208304
</>
209305
)

src/locale/ar.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
"menu_group_get_started": "البداية",
162162
"menu_group_analysis": "تحليل الرحلات",
163163
"menu_group_maps": "الخرائط",
164-
"menu_group_community": "المجتمع",
164+
"menu_section_community": "المجتمع",
165165
"report_a_bug_title": "الإبلاغ عن خطأ",
166166
"how_to_donate_title": "كيف أتبرّع؟",
167167
"how_to_donate_text": "خلف الكواليس يعمل فريق صغير ومتفانٍ، يهتمّ بتنظيم لقاءات التطوير والهاكاثونات وصيانة السيرڤرات، يتنقّل بين المكاتب الحكومية للإقناع والضغط والمساعدة على إصدار المزيد من قواعد البيانات؛ يتأكّد من أنّ الإعلام يعرف عملنا ويعرف كيف يتواصل معنا للحصول على البيانات والتحليلات، ويقوم بالكثير من العمل الجاري حتّى تُحدِث مشاريعنا تغييراً في العالم. نحن بحاجة لدعم المجتمع للاستمرار في العمل من أجل حكومة أكثر انفتاحاً. كونوا فرسان الشفافية وانضمّوا للدعم الشهري.",

src/locale/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@
191191
"menu_group_get_started": "Get Started",
192192
"menu_group_analysis": "Trip Analysis",
193193
"menu_group_maps": "Maps",
194-
"menu_group_community": "Community",
194+
"menu_section_data": "Data",
195+
"menu_section_community": "Community",
196+
"menu_sections_label": "Menu sections",
195197
"report_a_bug_title": "Report a bug",
196198
"how_to_donate_title": "How can I donate?",
197199
"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.",

src/locale/he.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@
191191
"menu_group_get_started": "התחלה",
192192
"menu_group_analysis": "ניתוח נסיעות",
193193
"menu_group_maps": "מפות",
194-
"menu_group_community": "קהילה",
194+
"menu_section_data": "נתונים",
195+
"menu_section_community": "קהילה",
196+
"menu_sections_label": "מדורי התפריט",
195197
"report_a_bug_title": "דיווח על באג",
196198
"how_to_donate_title": "כיצד לתרום?",
197199
"how_to_donate_text": "מאחורי הקלעים של הפרוייקטים פועל צוות קטן ומסור, שדואג לארגון מפגשי הפיתוח וההאקתונים ותחזוקת השרתים, שמכתת רגליו בין משרדי ממשלה כדי לשכנע, ללחוץ, ולנדנד לשחרר עוד ועוד מאגרים; מוודא שהתקשורת תכיר את העבודה שלנו וגם תדע לפנות אלינו בשביל נתונים וניתוחים, ועושה עוד המון עבודה שוטפת כדי שהפרוייקטים שלנו ייצרו שינוי בעולם. אנחנו זקוקים לתמיכה של הקהילה כדי להמשיך ולפעול למען ממשל פתוח יותר. היו אבירי שקיפות והצטרפו בתמיכה חודשית.",

src/locale/ru.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
"menu_group_get_started": "Начало",
162162
"menu_group_analysis": "Анализ поездок",
163163
"menu_group_maps": "Карты",
164-
"menu_group_community": "Сообщество",
164+
"menu_section_community": "Сообщество",
165165
"report_a_bug_title": "Сообщить об ошибке",
166166
"how_to_donate_title": "Как я могу пожертвовать?",
167167
"how_to_donate_text": "За кулисами проектов стоит небольшая, преданная команда, которая заботится об организации встреч по разработке и хакатонов и обслуживании серверов, которая ставит ноги между правительственными офисами, чтобы убеждать, давить и раскачивать для выпуска все больше и больше репозиториев; Убедиться, что СМИ знают нашу работу, а также знают, как связаться с нами для получения данных и аналитики, и делает много постоянной работы, чтобы наши проекты изменились в мире. Нам нужна поддержка сообщества, чтобы продолжать работать для более открытого правительства. Были рыцари прозрачности и присоединились к ежемесячной поддержке.",

tests/menu.spec.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,60 @@
1+
import { Page } from '@playwright/test'
12
import { expect, setupTest, test } from './utils'
23

3-
const MENU_ITEMS = [
4-
'ראשי',
4+
const DATA_MENU_ITEMS = [
55
'מסלול נסיעה',
66
'היסטוריית נסיעות',
77
'נסיעות שלא בוצעו',
88
'דפוסי נסיעות שלא בוצעו',
99
'חברה מפעילה',
10+
'רכב',
11+
'רכבת ישראל',
1012
'מפת תחבורה',
1113
'מפת מהירות',
12-
'קול קורא',
13-
'אודות',
14-
'לתרומות',
1514
]
1615

16+
const COMMUNITY_MENU_ITEMS = ['קול קורא', 'אודות', 'לתרומות']
17+
18+
const menuLinks = (page: Page) => page.locator('.sidebar-menu:visible').locator('ul > li a')
19+
1720
test.beforeEach(async ({ page }) => {
1821
await setupTest(page)
1922
})
2023

2124
test('should display logo and menu items correctly', async ({ page }) => {
2225
await expect(page.locator('.main-header .header-logo')).toHaveAccessibleName('דאטאבוס')
23-
await expect(page.locator('ul > li a')).toContainText(MENU_ITEMS)
26+
await expect(menuLinks(page)).toContainText(DATA_MENU_ITEMS)
27+
})
28+
29+
// The menu carries no "home" row — the header logo is the way back. If that ever stops
30+
// being a link home, the sidebar has to grow the row back.
31+
test('the logo is the way back to the homepage', async ({ page }) => {
32+
await page.getByRole('tab', { name: 'קהילה' }).click()
33+
await page.getByRole('link', { name: 'אודות', exact: true }).click()
34+
await expect(page).toHaveURL(/\/about/)
35+
await page.locator('.main-header .header-logo').click()
36+
await expect(page).toHaveURL((url) => url.pathname === '/')
37+
})
38+
39+
test('the community tab swaps the list', async ({ page }) => {
40+
await page.getByRole('tab', { name: 'קהילה' }).click()
41+
await expect(menuLinks(page)).toContainText(COMMUNITY_MENU_ITEMS)
42+
})
43+
44+
test('the tab follows the open page', async ({ page }) => {
45+
await page.getByRole('tab', { name: 'קהילה' }).click()
46+
await page.getByRole('link', { name: 'אודות', exact: true }).click()
47+
await expect(page).toHaveURL(/\/about/)
48+
// Landing on a community page must leave the community tab selected, or the menu
49+
// would contradict the URL.
50+
await expect(page.getByRole('tab', { name: 'קהילה' })).toHaveAttribute('aria-selected', 'true')
51+
})
52+
53+
test('a deep link to a community page opens on the community tab', async ({ page }) => {
54+
await page.goto('/about')
55+
await page.locator('.preloader').waitFor({ state: 'hidden' })
56+
await expect(page.getByRole('tab', { name: 'קהילה' })).toHaveAttribute('aria-selected', 'true')
57+
await expect(menuLinks(page)).toContainText(COMMUNITY_MENU_ITEMS)
2458
})
2559

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

tests/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,29 @@ export const setupTest = async (page: Page, lng: string = 'he') => {
164164
await page.locator('.preloader').waitFor({ state: 'hidden' })
165165
}
166166

167+
/**
168+
* The sidebar splits its pages across data/community tabs, so a page's link is only in
169+
* the DOM while its own tab is selected. Rather than mirror the section table here (which
170+
* would need updating every time a page moves between tabs), try each tab until the link
171+
* appears.
172+
*/
173+
const openSidebarSectionOf = async (page: Page, link: Locator) => {
174+
if (await link.count()) return
175+
// `:visible` because the sider and the mobile drawer each render a menu, and only one
176+
// of them is on screen at a time.
177+
for (const tab of await page.locator('.sidebar-menu:visible').getByRole('tab').all()) {
178+
await tab.click()
179+
if (await link.count()) return
180+
}
181+
}
182+
167183
export const visitPage = async (page: Page, label: (typeof PAGES)[number]['label']) => {
168184
// Scoped to the nav: the homepage repeats several of these labels on its own link
169185
// cards, so an unscoped name match would resolve to two elements.
170186
const link = page
171187
.locator('.sidebar-menu')
172188
.getByRole('link', { name: i18next.t(label), exact: true })
189+
await openSidebarSectionOf(page, link)
173190
const href = await link.getAttribute('href')
174191
// Register waitForURL before clicking to avoid missing fast client-side navigations
175192
const navigationPromise = href

0 commit comments

Comments
 (0)