Implement mobile-first navigation with tabs, server-side auto-connect, improve mobile UX (#1008)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2025-12-27 17:02:49 +01:00
committed by GitHub
parent 8f86d272c7
commit 4de52aba7c
45 changed files with 1381 additions and 224 deletions

View File

@@ -8,6 +8,7 @@ import { connect } from 'react-redux'
import { List } from 'immutable'
import { Sidebar } from '../Sidebar'
import { useResizeDetector } from 'react-resize-detector'
import MobileTabs from './MobileTabs'
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
const ReactSplitPane = ReactSplitPaneImport as any
@@ -21,13 +22,27 @@ interface Props {
function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width)
// Use useState with lazy initialization to get initial mobile state
const [isMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
// Use state for mobile detection that updates on resize
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
const [mobileTab, setMobileTab] = React.useState(0) // 0 = topics, 1 = details
const [height, setHeight] = React.useState<string | number>('100%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
// Update mobile state on resize
React.useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
// Set initial state
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
@@ -71,6 +86,83 @@ function ContentView(props: Props) {
}
}, [props.chartPanelItems])
// Mobile view with tab switcher
if (isMobile) {
// Expose tab switching functions for other components to call
React.useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).switchToDetailsTab = () => setMobileTab(1)
(window as any).switchToTopicsTab = () => setMobileTab(0)
}
return () => {
if (typeof window !== 'undefined') {
delete (window as any).switchToDetailsTab
delete (window as any).switchToTopicsTab
}
}
}, [])
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 64px)', // Full viewport minus titlebar
width: '100%',
}
const tabContentStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0, // Critical for flex children with overflow
width: '100%',
overflow: 'hidden',
position: 'relative',
}
// Tree container needs explicit height for the Tree component's height: 100% to work
const treeContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
}
const sidebarContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
overflow: 'auto',
}
return (
<div style={mobileContainerStyle}>
<MobileTabs value={mobileTab} onChange={setMobileTab} />
<div style={tabContentStyle}>
{/* Topics tab */}
{mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{mobileTab === 1 && (
<div style={sidebarContainerStyle}>
<Sidebar connectionId={props.connectionId} />
</div>
)}
</div>
</div>
)
}
// Desktop view with split panes
return (
<div className={props.paneDefaults}>
<span>
@@ -113,7 +205,7 @@ function ContentView(props: Props) {
<div
className={props.paneDefaults}
style={{
minWidth: isMobile ? '100%' : '250px',
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'

View File

@@ -0,0 +1,69 @@
import * as React from 'react'
import { Tabs, Tab, Box } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
interface Props {
classes: any
value: number
onChange: (value: number) => void
}
function MobileTabs(props: Props) {
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
props.onChange(newValue)
}
return (
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
<Tabs
value={props.value}
onChange={handleChange}
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
aria-label="Topics and Details tabs"
>
<Tab
label="Topics"
data-testid="mobile-tab-topics"
aria-label="View topics tree"
id="mobile-tab-0"
aria-controls="mobile-tabpanel-0"
/>
<Tab
label="Details"
data-testid="mobile-tab-details"
aria-label="View topic details"
id="mobile-tab-1"
aria-controls="mobile-tabpanel-1"
/>
</Tabs>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
position: 'relative' as 'relative',
zIndex: 1,
minHeight: '56px', // Touch-friendly tab height
'& .MuiTab-root': {
minHeight: '56px', // 48px minimum + padding
fontSize: '16px', // Prevent iOS zoom
fontWeight: 500,
padding: theme.spacing(1.5, 2),
textTransform: 'none' as 'none', // Better readability
'&:active': {
opacity: 0.7, // Touch feedback
},
},
'& .MuiTabs-indicator': {
height: '3px', // Thicker indicator for better visibility
},
},
})
export default withStyles(styles)(MobileTabs)

View File

@@ -23,7 +23,15 @@ function SearchBar(props: {
const [hasFocus, setHasFocus] = useState(false)
const inputRef = useRef<HTMLInputElement>()
const onFocus = useCallback(() => setHasFocus(true), [])
const onFocus = useCallback(() => {
setHasFocus(true)
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
if ((window as any).switchToTopicsTab) {
(window as any).switchToTopicsTab()
}
}
}, [])
const onBlur = useCallback(() => setHasFocus(false), [])
const clearFilter = useCallback(() => {
@@ -57,8 +65,8 @@ function SearchBar(props: {
})
return (
<div className={classes.search}>
<div className={classes.searchIcon}>
<div className={classes.search} role="search">
<div className={classes.searchIcon} aria-hidden="true">
<Search />
</div>
<InputBase
@@ -67,6 +75,7 @@ function SearchBar(props: {
onFocus,
onBlur,
ref: inputRef,
'aria-label': 'Search topics',
}}
onChange={onFilterChange}
placeholder="Search…"
@@ -130,16 +139,37 @@ const styles = (theme: Theme) => ({
justifyContent: 'center' as 'center',
},
inputRoot: {
color: 'inherit' as 'inherit',
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity
width: '100%',
'& input': {
color: `${theme.palette.common.white} !important`, // Target input element directly
},
},
inputInput: {
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(6),
paddingLeft: `${theme.spacing(6)} !important`, // Ensure padding is applied (48px)
transition: theme.transitions.create('width'),
width: '100%',
color: `${theme.palette.common.white} !important`, // High contrast white text with priority
fontSize: '16px', // Prevent iOS zoom on focus
'&::placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`, // Semi-transparent white placeholder
opacity: 1,
},
'&::-webkit-input-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
'&::-moz-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
// Improve mobile input handling
[theme.breakpoints.down('md')]: {
fontSize: '16px', // Prevent zoom
WebkitAppearance: 'none',
touchAction: 'manipulation',
},
},
})

View File

@@ -22,6 +22,9 @@ const styles = (theme: Theme) => ({
[theme.breakpoints.up(750)]: {
display: 'block' as 'block',
},
[theme.breakpoints.up('md')]: {
display: 'block' as 'block',
},
whiteSpace: 'nowrap' as 'nowrap',
},
disconnectIcon: {
@@ -37,9 +40,17 @@ const styles = (theme: Theme) => ({
},
disconnect: {
margin: 'auto 8px auto auto',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
logout: {
margin: 'auto 0 auto 8px',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
disconnectLabel: {
color: theme.palette.primary.contrastText,