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
22 changes: 22 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ jobs:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
# A push-triggered build has no pull_request for Eyes to correlate it with, so the
# commit must be announced explicitly — see the Applitools GitHub Actions docs,
# "Preparing For Commit Push Action". Their example passes owner/repo/ref as
# BranchName; we pass the bare ref instead, because that is the branch every test
# in this account is actually filed under (the SDK derives it from GITHUB_REF).
- name: Notify Applitools of the commit push
if: github.event_name == 'push' && env.APPLITOOLS_API_KEY
run: |
curl -sS -L -d '' -X POST \
"https://eyesapi.applitools.com/api/externals/github/push?apiKey=$APPLITOOLS_API_KEY&CommitSha=$APPLITOOLS_BATCH_ID&BranchName=$GITHUB_REF_NAME" \
-o /dev/null -w 'push-notify HTTP %{http_code}\n'
- name: Configure blocked external hosts
uses: ./.github/actions/block-network
- name: Prepare for Testing
Expand Down Expand Up @@ -275,6 +286,17 @@ jobs:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
# A push-triggered build has no pull_request for Eyes to correlate it with, so the
# commit must be announced explicitly — see the Applitools GitHub Actions docs,
# "Preparing For Commit Push Action". Their example passes owner/repo/ref as
# BranchName; we pass the bare ref instead, because that is the branch every test
# in this account is actually filed under (the SDK derives it from GITHUB_REF).
- name: Notify Applitools of the commit push
if: github.event_name == 'push' && env.APPLITOOLS_API_KEY
run: |
curl -sS -L -d '' -X POST \
"https://eyesapi.applitools.com/api/externals/github/push?apiKey=$APPLITOOLS_API_KEY&CommitSha=$APPLITOOLS_BATCH_ID&BranchName=$GITHUB_REF_NAME" \
-o /dev/null -w 'push-notify HTTP %{http_code}\n'
- name: Configure blocked external hosts
uses: ./.github/actions/block-network
- name: Prepare for Testing
Expand Down
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
234 changes: 150 additions & 84 deletions src/layout/sidebar/menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
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
/* The desktop sider has a single screen to fit the whole menu in, so its rows are
tighter and the list sizes itself to the sider (see NavList); the mobile drawer
scrolls the full viewport and keeps roomy, touch-sized rows. */
compact?: boolean
}

const MENU_GROUPS = [
Expand All @@ -36,103 +46,159 @@ 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; stop short of it.
const MIN_ROW_HEIGHT = 28

/**
* Auto-fit: in the sider the list is a flex column sized to its container, so the rows
* absorb the available height and shrink toward MIN_ROW_HEIGHT on a short viewport
* instead of overflowing; past that floor the sider'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 rowHeight = compact ? ROW_HEIGHT.compact : ROW_HEIGHT.roomy

return {
key,
type: 'group',
label,
children,
padding: compact ? '4px 8px 8px' : '8px 10px 12px',

...(compact && {
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
boxSizing: 'border-box',
}),

'& .MuiListSubheader-root': {
padding: compact ? '6px 12px 2px' : '8px 12px 6px',
marginBlockStart: compact ? 4 : 10,
backgroundColor: 'transparent',
fontSize: 11,
fontWeight: 700,
lineHeight: 1.6,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: theme.palette.text.secondary,
...(compact && { flex: '0 0 auto' }),
},

'& .MuiListItem-root': {
...(compact && { flex: `0 1 ${rowHeight}px`, minHeight: MIN_ROW_HEIGHT }),
},

'& .MuiListItemButton-root': {
// In the sider the row's height comes from its flex item, so the button just
// fills it; in the drawer the button is what sets the height.
...(compact ? { height: '100%', minHeight: 0 } : { minHeight: rowHeight, marginBottom: 4 }),
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()

const handleDonateClick = (e: React.MouseEvent) => {
e.preventDefault()
// 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 = (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 { pathname } = useLocation()
const [current, setCurrent] = useState(pathname || '/')
const renderItem = (path: string) => {
const page = pageByPath.get(path)
if (!page) return null

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.

Loading
Loading