Implement Mobile Footer Navigation and Enhance Dashboard Layout
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added MobileFooterNavigation component for improved navigation on mobile devices, featuring segmented navigation for key sections.
- Updated DashboardLayout to conditionally render MobileFooterNavigation based on screen size.
- Enhanced DashboardNavigation to dynamically adjust menu items based on mobile view, improving user experience.
- Refactored CSS styles for mobile footer navigation to ensure proper layout and responsiveness.
- Adjusted ObjectTable component to account for new mobile layout, ensuring consistent display across devices.
This commit is contained in:
Tom Butcher 2026-09-15 03:26:21 +01:00
parent 968dc0667a
commit 2fa5c288d2
8 changed files with 296 additions and 85 deletions

View File

@ -3356,3 +3356,96 @@ body.objectKanbanColumnResizing * {
.ant-steps-item-content {
min-height: 0;
}
.mobile-footer-navigation.ant-layout-footer {
flex: 0 0 auto;
width: 100%;
line-height: normal;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
background: var(--color-mobile-footer-bg);
color: var(--color-text);
border-top: 1px solid var(--color-mobile-footer-item-border);
}
.mobile-footer-navigation-tabs.segmented-nav {
display: flex;
width: 100%;
}
.mobile-footer-navigation .segmented-nav-group {
width: 100%;
}
.mobile-footer-navigation .segmented-nav-item {
flex: 1 1 0;
min-width: 0;
flex-direction: column;
gap: 10px;
padding: 12px;
border: none;
color: var(--color-text);
transition:
background 0.2s ease,
color 0.2s ease;
}
.mobile-footer-navigation .segmented-nav-item-icon {
padding-inline: 0;
min-width: 0;
}
.mobile-footer-navigation .segmented-nav-item-icon-contents {
min-height: 24px;
line-height: 24px;
}
.mobile-footer-navigation .segmented-nav-item-icon-contents .anticon {
font-size: 24px;
}
.mobile-footer-navigation .segmented-nav-thumb {
background: var(--color-mobile-footer-item-bg);
border: none;
}
.mobile-footer-navigation .segmented-nav-item-selected,
.mobile-footer-navigation
.segmented-nav-item:not(.segmented-nav-item-disabled):hover {
background: var(--color-mobile-footer-item-bg);
border: none;
}
.mobile-footer-navigation .segmented-nav-item-selected {
color: var(--color-primary);
}
.mobile-footer-navigation .segmented-nav-item-selected .anticon,
.mobile-footer-navigation .segmented-nav-item-selected svg {
color: inherit;
}
.mobile-footer-navigation .segmented-nav-item-label {
min-height: 0;
line-height: 1.2;
padding-inline: 0;
font-size: 11px;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-layout.is-mobile {
overflow: hidden;
}
.mobile-menu.ant-menu-horizontal {
max-width: 32px;
margin-left: 4px;
margin-right: 12px;
}
.mobile-menu.ant-menu-horizontal .ant-menu-submenu {
padding: 0;
}

View File

@ -11,14 +11,17 @@ import DashboardNavigation from './common/DashboardNavigation'
import DashboardBreadcrumb from './common/DashboardBreadcrumb'
import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive'
const { Content } = Layout
const DashboardLayout = ({ children }) => {
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isFinance = location.pathname.startsWith('/dashboard/finance')
@ -49,7 +52,7 @@ const DashboardLayout = ({ children }) => {
<MessageProvider>
<Layout
style={{ height: 'var(--unit-100vh)' }}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout`}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
>
<DashboardNavigation />
<DashboardSidebarSplitter sidebar={sidebar}>
@ -68,6 +71,7 @@ const DashboardLayout = ({ children }) => {
</Content>
</Layout>
</DashboardSidebarSplitter>
{isMobile ? <MobileFooterNavigation /> : null}
</Layout>
</MessageProvider>
)

View File

@ -22,7 +22,6 @@ import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive'
import KeyboardShortcut from './KeyboardShortcut'
import UserProfilePopover from './UserProfilePopover'
import ElipsisText from './ElipsisText'
import FarmControlLogo from '../../Logos/FarmControlLogo'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
@ -42,10 +41,14 @@ import DashboardWindowButtons from './DashboardWindowButtons'
import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher'
import {
filterSidebarItemsByListPermission,
getSidebarDefaultPath,
getSidebarItems,
getSidebarMenuSections,
getSidebarSelectedKey,
isSidebarSectionVisible
} from '../../../database/Sidebars'
import { getSidebarIconNode } from '../../Icons/sidebarIconMap'
import { useAppUpdateContext } from '../context/AppUpdateContext'
import { useThemeContext } from '../context/ThemeContext'
@ -53,6 +56,29 @@ import { getEffectiveAppearance } from '../../../database/Settings'
const { Text } = Typography
const mapSidebarItemsToMenu = (items, navigate) =>
items.map((item) => {
if (item?.type === 'divider') {
return item
}
const mappedItem = {
key: item.key,
icon: item.icon || getSidebarIconNode(item.iconKey),
label: item.label
}
if (item.path) {
mappedItem.onClick = () => navigate(item.path)
}
if (item?.children && Array.isArray(item.children)) {
mappedItem.children = mapSidebarItemsToMenu(item.children, navigate)
}
return mappedItem
})
const DashboardNavigation = () => {
const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext)
@ -71,11 +97,6 @@ const DashboardNavigation = () => {
const navigate = useNavigate()
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [selectedMenuItem, setSelectedMenuItem] = useState({
key: 'production',
label: 'Production',
icon: <ProductionIcon />
})
const isMobile = useMediaQuery({ maxWidth: 768 })
const {
platform,
@ -116,6 +137,7 @@ const DashboardNavigation = () => {
])
const includeDev = import.meta.env.DEV
const includeDevItems = import.meta.env.MODE === 'development'
const mainMenuItems = useMemo(() => {
const iconStyles = {
fontSize: !showNavigationLabels ? '16px' : undefined,
@ -135,7 +157,10 @@ const DashboardNavigation = () => {
key: 'production',
label: 'Production',
className: 'electrobun-webkit-app-region-no-drag',
icon: navigationIcon(<ProductionIcon style={iconStyles} />, 'Production')
icon: navigationIcon(
<ProductionIcon style={iconStyles} />,
'Production'
)
},
{
key: 'inventory',
@ -166,6 +191,29 @@ const DashboardNavigation = () => {
)
}, [includeDev, showNavigationLabels, userProfile])
const sidebarMenuItems = useMemo(() => {
const items = filterSidebarItemsByListPermission(
getSidebarItems(selectedKey, { includeDev: includeDevItems }),
userProfile
)
return mapSidebarItemsToMenu(items, navigate)
}, [includeDevItems, navigate, selectedKey, userProfile])
const selectedSidebarItemKey = useMemo(
() =>
getSidebarSelectedKey(selectedKey, location.pathname, {
includeDev: includeDevItems,
userProfile
}),
[includeDevItems, location.pathname, selectedKey, userProfile]
)
const headerMenuItems = isMobile
? sidebarMenuItems
: showNavigationLabels
? mainMenuItems
: mainMenuItems.map((item) => ({ ...item, label: undefined }))
const [userPopoverOpen, setUserPopoverOpen] = useState(false)
const userPopoverContent = (
@ -174,15 +222,10 @@ const DashboardNavigation = () => {
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 2) {
const nextItem =
mainMenuItems.find((item) => item.key == pathParts[1]) ||
mainMenuItems[0]
if (nextItem) {
setSelectedMenuItem(nextItem)
setSelectedKey(nextItem.key)
}
}
const sectionKey = pathParts[1]
if (!sectionKey) return
const nextItem = mainMenuItems.find((item) => item.key == sectionKey)
setSelectedKey(nextItem?.key || sectionKey)
}, [location.pathname, mainMenuItems])
useEffect(() => {
@ -213,6 +256,29 @@ const DashboardNavigation = () => {
const showControls = !isElectron || authenticated
const menu = (
<Menu
mode='horizontal'
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels && !isMobile ? 'no-navigation-labels' : ''}${isMobile ? 'mobile-menu' : ''}`}
items={headerMenuItems}
style={{
flexWrap: 'wrap',
flexGrow: 1,
border: 0,
minWidth: 0
}}
onClick={isMobile ? undefined : handleMainMenuClick}
selectedKeys={[isMobile ? selectedSidebarItemKey : selectedKey]}
overflowedIndicator={
<Button
type='text'
icon={<MenuIcon />}
style={{ marginBottom: '4px' }}
/>
}
/>
)
const navigationContents = (
<Flex style={{ width: '100%' }} align='center'>
{isMacOSApp ? <DashboardWindowButtons /> : null}
@ -225,6 +291,7 @@ const DashboardNavigation = () => {
/>
</>
) : null}
{showControls && isMobile && menu}
{showDesktopLogo == true ? (
<FarmControlLogo
style={{
@ -241,57 +308,8 @@ const DashboardNavigation = () => {
}}
/>
) : null}
{isMobile && (
<Flex
gap={'small'}
align='center'
style={{
marginLeft: '16px',
minWidth: 0, // allow children to shrink
maxWidth: '100%' // responsive
}}
>
{selectedMenuItem?.icon}
<ElipsisText
style={{
minWidth: 0,
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
flex: 1
}}
>
{selectedMenuItem?.label}
</ElipsisText>
</Flex>
)}
<div style={{ flexGrow: 1 }}>
{showControls && (
<Menu
mode='horizontal'
className={`${isElectron ? 'electron-navigation' : null} ${!showNavigationLabels ? 'no-navigation-labels' : ''}`}
items={
showNavigationLabels
? mainMenuItems
: mainMenuItems.map((item) => ({ ...item, label: undefined }))
}
style={{
flexWrap: 'wrap',
flexGrow: isMobile ? 0 : 1,
border: 0,
width: isMobile ? '64px' : 'unset'
}}
onClick={handleMainMenuClick}
selectedKeys={[selectedKey]}
overflowedIndicator={
<Button
type='text'
icon={<MenuIcon />}
style={{ marginBottom: '4px' }}
/>
}
/>
)}
{showControls && !isMobile && menu}
{!showControls && (
<Text
type='secondary'
@ -306,7 +324,6 @@ const DashboardNavigation = () => {
</Text>
)}
</div>
{isMobile && <div style={{ flexGrow: 1 }} />}
<div className='electrobun-webkit-app-region-no-drag'>
<Flex
@ -449,7 +466,7 @@ const DashboardNavigation = () => {
align='center'
className='ant-menu-light'
style={{
padding: '0 26px',
padding: isMobile ? '0 12px' : '0 26px',
height: '100%',
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}}

View File

@ -6,7 +6,6 @@ import {
useContext
} from 'react'
import { Layout, Menu, Flex, Button, Divider } from 'antd'
import { CaretDownFilled } from '@ant-design/icons'
import CollapseSidebarIcon from '../../Icons/CollapseSidebarIcon'
import ExpandSidebarIcon from '../../Icons/ExpandSidebarIcon'
import { useMediaQuery } from 'react-responsive'
@ -82,8 +81,6 @@ const DashboardSidebar = ({
return item
}
console.log(item)
const icon = item.icon || getSidebarIconNode(item.iconKey)
const mappedItem = {
key: item.key,
@ -114,16 +111,7 @@ const DashboardSidebar = ({
const _items = mapItemsRecursively(allowedItems)
if (isMobile) {
return (
<Menu
mode='horizontal'
selectedKeys={[selectedKey]}
items={_items}
_internalDisableMenuItemTitleTooltip
style={{ lineHeight: '40px' }}
overflowedIndicator={<Button type='text' icon={<CaretDownFilled />} />}
/>
)
return null
}
return (

View File

@ -158,8 +158,7 @@ const DashboardSidebarSplitter = ({ sidebar, children }) => {
if (isMobile) {
return (
<Layout>
{sidebarNode}
<Layout style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{children}
</Layout>
)

View File

@ -0,0 +1,98 @@
import { useContext, useMemo } from 'react'
import { Layout } from 'antd'
import { useMediaQuery } from 'react-responsive'
import { useLocation, useNavigate } from 'react-router-dom'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import {
getSidebarDefaultPath,
isSidebarSectionVisible
} from '../../../database/Sidebars'
import { getSidebarIconNode } from '../../Icons/sidebarIconMap'
import SegmentedNav from './SegmentedNav'
const { Footer } = Layout
const MobileFooterNavigation = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const { userProfile, authenticated } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const navigate = useNavigate()
const location = useLocation()
const includeDev = import.meta.env.DEV
const sectionItems = useMemo(
() =>
[
{
key: 'production',
label: 'Production',
iconKey: 'production'
},
{
key: 'inventory',
label: 'Inventory',
iconKey: 'inventory'
},
{
key: 'sales',
label: 'Sales',
iconKey: 'sales'
},
{
key: 'finance',
label: 'Finance',
iconKey: 'finance'
},
{
key: 'management',
label: 'Management',
iconKey: 'settings'
}
].filter((item) =>
isSidebarSectionVisible(item.key, { includeDev, userProfile })
),
[includeDev, userProfile]
)
const selectedKey = useMemo(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
const sectionKey = pathParts[1]
return (
sectionItems.find((item) => item.key === sectionKey)?.key ||
sectionItems[0]?.key
)
}, [location.pathname, sectionItems])
const options = useMemo(
() =>
sectionItems.map((item) => ({
value: item.key,
label: item.label,
icon: getSidebarIconNode(item.iconKey)
})),
[sectionItems]
)
const showControls = !isElectron || authenticated
if (!isMobile || !showControls || options.length === 0) {
return null
}
return (
<Footer className='mobile-footer-navigation ant-menu-light'>
<SegmentedNav
className='mobile-footer-navigation-tabs'
value={selectedKey}
options={options}
animated
onChange={(key) =>
navigate(getSidebarDefaultPath(key, { includeDev, userProfile }))
}
/>
</Footer>
)
}
export default MobileFooterNavigation

View File

@ -388,7 +388,7 @@ const ObjectTable = forwardRef(
const { getViewFromUrl } = useTableState()
var adjustedScrollHeight = scrollHeight
if (isMobile) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
adjustedScrollHeight = 'calc(var(--unit-100vh) - 348px)'
}
if (isCards || isKanban || isTimeline) {
adjustedScrollHeight = 'calc(var(--unit-100vh) - 210px)'

View File

@ -204,6 +204,18 @@ export const ThemeProvider = ({ children }) => {
'--color-text-placeholder',
isDarkMode ? '#4F4F4F' : '#BFBFBF'
)
root.style.setProperty(
'--color-mobile-footer-bg',
isDarkMode ? '#141414' : '#ffffff'
)
root.style.setProperty(
'--color-mobile-footer-item-bg',
isDarkMode ? '#1d1d1d' : '#f5f5f5'
)
root.style.setProperty(
'--color-mobile-footer-item-border',
isDarkMode ? '#303030' : '#f0f0f0'
)
}, [isDarkMode, primaryColorOverride])
const themeConfig = {