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:
@@ -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'
|
||||
|
||||
69
app/src/components/Layout/MobileTabs.tsx
Normal file
69
app/src/components/Layout/MobileTabs.tsx
Normal 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)
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user