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

@@ -2,6 +2,7 @@ import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
const ConnectionSettingsAny = ConnectionSettings as any
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
@@ -66,10 +67,15 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
</div>
<div className={classes.right} key={connection && connection.id}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
<div className={classes.toolbarContent}>
<div className={classes.desktopTitle}>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
</div>
<MobileConnectionSelector />
</div>
</Toolbar>
{this.renderSettings()}
</div>
@@ -86,6 +92,20 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
},
toolbarContent: {
width: '100%',
display: 'flex',
alignItems: 'center',
},
desktopTitle: {
display: 'flex',
alignItems: 'center',
flex: 1,
// Hide on mobile - connection selector will take its place
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
root: {
margin: `calc((100vh - ${connectionHeight}) / 2) auto 0 auto`,
minWidth: '800px',
@@ -93,6 +113,14 @@ const styles = (theme: Theme) => ({
height: connectionHeight,
outline: 'none' as 'none',
display: 'flex' as 'flex',
// Mobile responsive adjustments
[theme.breakpoints.down('md')]: {
minWidth: '95vw',
maxWidth: '95vw',
height: '85vh',
margin: '7.5vh auto 0 auto',
flexDirection: 'column' as 'column',
},
},
left: {
borderRightStyle: 'dotted' as 'dotted',
@@ -103,12 +131,21 @@ const styles = (theme: Theme) => ({
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
// Mobile: hide profile list to save space
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
right: {
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
flex: 10,
// Mobile: enable scrolling
[theme.breakpoints.down('md')]: {
borderRadius: `${theme.shape.borderRadius}px`,
overflowY: 'auto' as 'auto',
},
},
connectionUri: {
width: '27em',

View File

@@ -0,0 +1,125 @@
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const styles = (theme: Theme) => ({
container: {
display: 'none',
// Only show on mobile, takes full width
[theme.breakpoints.down('md')]: {
display: 'flex',
flex: 1,
alignItems: 'center',
gap: theme.spacing(1),
},
},
select: {
flex: 1,
fontSize: '1rem',
'& .MuiSelect-select': {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
},
},
addButton: {
padding: theme.spacing(1),
},
})
interface Props {
classes: any
connections: Array<{ id: string; name?: string; host?: string }>
currentConnectionId?: string
actions: typeof connectionManagerActions
}
class MobileConnectionSelector extends React.PureComponent<Props, {}> {
private handleConnectionChange = (event: SelectChangeEvent<string>) => {
const connectionId = event.target.value
this.props.actions.selectConnection(connectionId)
}
private handleCreateConnection = () => {
this.props.actions.createConnection()
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
return connection.name || connection.host || 'Unnamed Connection'
}
public render() {
const { classes, connections, currentConnectionId } = this.props
if (!connections || connections.length === 0) {
return null
}
return (
<div className={classes.container}>
<Select
className={classes.select}
value={currentConnectionId || ''}
onChange={this.handleConnectionChange}
aria-label="Select MQTT connection"
displayEmpty
MenuProps={{
PaperProps: {
style: {
maxHeight: '60vh',
},
},
}}
>
{connections.map(conn => {
const isConnected = conn.id === currentConnectionId
const displayName = this.getConnectionDisplayName(conn)
return (
<MenuItem key={conn.id} value={conn.id}>
{displayName}
{isConnected && ' (Connected)'}
</MenuItem>
)
})}
</Select>
<IconButton
className={classes.addButton}
onClick={this.handleCreateConnection}
aria-label="Create new connection"
size="medium"
>
<Add />
</IconButton>
</div>
)
}
}
const mapStateToProps = (state: AppState) => {
const connectionManager = state.connectionManager
const connections = connectionManager && connectionManager.connections
? Object.values(connectionManager.connections).map(conn => ({
id: conn.id,
name: conn.name,
host: conn.host,
}))
: []
return {
connections,
currentConnectionId: state.connectionManager?.selected,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(MobileConnectionSelector))

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,

View File

@@ -2,17 +2,22 @@ import * as React from 'react'
import BooleanSwitch from './BooleanSwitch'
import BrokerStatistics from './BrokerStatistics'
import ChevronRight from '@mui/icons-material/ChevronRight'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions } from '../../actions'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { shell } from 'electron'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import {
Button,
Divider,
Drawer,
IconButton,
@@ -75,12 +80,26 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.secondary,
cursor: 'pointer' as 'pointer',
},
mobileButtons: {
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1),
// Only show on mobile
[theme.breakpoints.up('md')]: {
display: 'none' as 'none',
},
},
mobileButton: {
justifyContent: 'flex-start',
},
})
interface Props {
actions: {
settings: typeof settingsActions
global: typeof globalActions
connection: typeof connectionActions
}
autoExpandLimit: number
classes: any
@@ -219,6 +238,7 @@ class Settings extends React.PureComponent<Props, {}> {
</Typography>
<Divider style={{ userSelect: 'none' }} />
</div>
<MobileActionButtons classes={classes} actions={actions} />
<div>
{this.renderAutoExpand()}
{this.renderNodeOrder()}
@@ -238,6 +258,52 @@ class Settings extends React.PureComponent<Props, {}> {
}
}
// Mobile action buttons component (disconnect/logout)
function MobileActionButtons({ classes, actions }: { classes: any; actions: any }) {
const { authDisabled } = useAuth()
const handleLogout = async () => {
// Disconnect first
actions.connection.disconnect()
// Clear credentials from sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
// Reload page to reset all state and show login dialog
if (typeof window !== 'undefined') {
window.location.reload()
}
}
return (
<div className={classes.mobileButtons}>
<Button
variant="outlined"
startIcon={<CloudOff />}
onClick={actions.connection.disconnect}
className={classes.mobileButton}
data-testid="mobile-disconnect-button"
>
Disconnect
</Button>
{isBrowserMode && !authDisabled && (
<Button
variant="outlined"
startIcon={<Logout />}
onClick={handleLogout}
className={classes.mobileButton}
data-testid="mobile-logout-button"
>
Logout
</Button>
)}
</div>
)
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.get('autoExpandLimit'),
@@ -254,6 +320,7 @@ const mapDispatchToProps = (dispatch: any) => {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}

View File

@@ -24,7 +24,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
const selectOption = useCallback(
(decoder: MessageDecoder, format: string) => {
if (!node) {
if (!node || !node.viewModel) {
return
}
@@ -55,7 +55,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
return (
<Button onClick={handleToggle}>
{props.node?.viewModel.decoder?.format ?? props.node?.type}
{props.node?.viewModel?.decoder?.format ?? props.node?.type}
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
{({ TransitionProps, placement }) => (
<Grow

View File

@@ -52,8 +52,21 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
return null
}
// On mobile, the expand button has its own click handler separate from topic selection
// On desktop, clicking anywhere (including expander) selects and toggles via didClickTitle
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
const onClick = isMobile ? props.toggleCollapsed : undefined
return (
<span key="expander" className={props.classes.expander} onClick={props.toggleCollapsed}>
<span
key="expander"
className={props.classes.expander}
onClick={onClick}
role="button"
aria-label={props.collapsed ? 'Expand topic' : 'Collapse topic'}
aria-expanded={!props.collapsed}
tabIndex={isMobile ? 0 : -1}
>
{props.collapsed ? '▶' : '▼'}
</span>
)
@@ -83,27 +96,39 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
)
}
const styles = (theme: Theme) => ({
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: theme.spacing(0.25),
userSelect: 'none' as 'none',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
},
})
const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
fontSize: isMobile ? '15px' : 'inherit', // Slightly larger on mobile
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
fontSize: isMobile ? '16px' : 'inherit', // Base 16px on mobile to prevent zoom
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: isMobile ? theme.spacing(1) : theme.spacing(0.25), // Larger touch area
paddingLeft: isMobile ? theme.spacing(0.5) : 0,
minWidth: isMobile ? '32px' : 'auto', // 40px total width on mobile for touch
display: 'inline-block' as 'inline-block',
textAlign: 'center' as 'center',
userSelect: 'none' as 'none',
fontSize: isMobile ? '18px' : 'inherit', // Larger icon on mobile
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
fontSize: isMobile ? '14px' : 'inherit',
},
}
}
export default withStyles(styles)(memo(TreeNodeTitle))

View File

@@ -61,8 +61,22 @@ function TreeNodeComponent(props: Props) {
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
didSelectTopic()
setCollapsedOverride(!isCollapsed)
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
if (isMobile) {
// Mobile: Only select the topic (no toggle)
// Expanding is handled by the separate expand button click
didSelectTopic()
// Switch to details tab on mobile after selecting a topic
if (typeof window !== 'undefined' && (window as any).switchToDetailsTab) {
(window as any).switchToDetailsTab()
}
} else {
// Desktop: Original behavior - select AND toggle (click anywhere works)
didSelectTopic()
setCollapsedOverride(!isCollapsed)
}
},
[isCollapsed, didSelectTopic]
)
@@ -122,6 +136,10 @@ function TreeNodeComponent(props: Props) {
onClick={didClickTitle}
tabIndex={-1}
onKeyDown={deleteTopicCallback}
role="treeitem"
aria-selected={selected}
aria-expanded={!isCollapsed}
aria-label={`Topic: ${name || treeNode.sourceEdge?.name || 'root'}`}
>
<TreeNodeTitle
lastUpdate={treeNode.lastUpdate}

View File

@@ -2,6 +2,8 @@ import { blueGrey } from '@mui/material/colors'
import { Theme } from '@mui/material/styles'
export const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
animationLight: {
willChange: 'auto',
@@ -25,7 +27,7 @@ export const styles = (theme: Theme) => {
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 0px 0px 0px',
padding: isMobile ? '1px 0px' : '1px 0px 0px 0px',
},
topicSelect: {
float: 'right' as 'right',
@@ -34,7 +36,7 @@ export const styles = (theme: Theme) => {
marginTop: '-1px',
},
subnodes: {
marginLeft: theme.spacing(1.5),
marginLeft: isMobile ? theme.spacing(2) : theme.spacing(1.5), // Increased indentation on mobile
},
selected: {
backgroundColor: (theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
@@ -42,15 +44,23 @@ export const styles = (theme: Theme) => {
hover: {},
title: {
borderRadius: '4px',
lineHeight: '1em',
lineHeight: isMobile ? '1.3em' : '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
height: '14px',
padding: '1px 4px 0 4px',
minHeight: isMobile ? '40px' : '14px', // 44px touch target on mobile (WCAG AA minimum)
height: 'auto' as 'auto',
padding: isMobile ? '8px 8px' : '1px 4px 0 4px', // Reduced padding, still touch-friendly
margin: '1px 0px',
fontSize: isMobile ? '16px' : 'inherit', // Prevent iOS zoom on focus
cursor: 'pointer' as 'pointer',
'&:hover': {
backgroundColor: theme.palette.mode === 'light' ? blueGrey[100] : theme.palette.primary.light,
},
// Better touch feedback on mobile
[theme.breakpoints.down('md')]: {
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
},
},
}
}