From e66fef21d5e90da88be9ee94f3a842f7bed90841 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 22 Jun 2026 17:06:48 -0600 Subject: [PATCH 1/6] Well detail main body responds to content width, not viewport. When edit or help panels shrink the page, cards stack in mobile order instead of staying in a crushed two-column grid. CoreWellInfo stats also stack on narrow cards. --- src/components/card/CoreWellInfo.tsx | 56 ++++--- src/hooks/index.ts | 1 + src/hooks/useContainerMinWidth.ts | 37 +++++ src/pages/ocotillo/thing/well-show.tsx | 211 +++++++++++++++++-------- src/test/pages/well-show.test.tsx | 2 + 5 files changed, 220 insertions(+), 87 deletions(-) create mode 100644 src/hooks/useContainerMinWidth.ts diff --git a/src/components/card/CoreWellInfo.tsx b/src/components/card/CoreWellInfo.tsx index a7f8a441..6639fdcf 100644 --- a/src/components/card/CoreWellInfo.tsx +++ b/src/components/card/CoreWellInfo.tsx @@ -1,6 +1,28 @@ import { Box, Paper, Skeleton, Typography } from '@mui/material' import { IWell } from '@/interfaces/ocotillo' +const STATS_WIDE_MIN_PX = 480 + +const statCellSx = (index: number) => ({ + px: 2, + py: 1.5, + borderColor: 'divider', + borderTop: index > 0 ? '1px solid' : 'none', + borderLeft: 'none', + [`@container (min-width: ${STATS_WIDE_MIN_PX}px)`]: { + borderTop: 'none', + borderLeft: index > 0 ? '1px solid' : 'none', + }, +}) + +const statsGridSx = { + display: 'grid', + gridTemplateColumns: '1fr', + [`@container (min-width: ${STATS_WIDE_MIN_PX}px)`]: { + gridTemplateColumns: 'repeat(3, 1fr)', + }, +} as const + export const CoreWellInfoCard = ({ well }: { well?: IWell }) => { if (!well) { return @@ -34,18 +56,13 @@ export const CoreWellInfoCard = ({ well }: { well?: IWell }) => { ] return ( - - + + {stats.map((stat, i) => ( - 0 ? '1px solid' : 'none', - borderColor: 'divider', - }} - > + { } const LoadingBar = () => ( - - + + {Array.from({ length: 3 }).map((_, i) => ( - 0 ? '1px solid' : 'none', - borderColor: 'divider', - }} - > + diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a3abc778..b39fcf72 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -21,3 +21,4 @@ export * from './useViewportBbox' export * from './useSearchModalState' export * from './useSidebarPanelSync' export * from './useWellDetails' +export * from './useContainerMinWidth' diff --git a/src/hooks/useContainerMinWidth.ts b/src/hooks/useContainerMinWidth.ts new file mode 100644 index 00000000..798cdb8f --- /dev/null +++ b/src/hooks/useContainerMinWidth.ts @@ -0,0 +1,37 @@ +import * as React from 'react' + +/** Content-area width at which WellShow switches to a two-column layout. */ +export const WELL_SHOW_TWO_COLUMN_MIN_PX = 880 + +/** + * Returns true when the observed element's content width is at least minWidth. + * Uses ResizeObserver so layout responds to available space (e.g. when side panels open). + */ +export function useContainerMinWidth( + ref: React.RefObject, + minWidth: number +) { + const [matches, setMatches] = React.useState(false) + + React.useEffect(() => { + const element = ref.current + if (!element) return + + const update = (width: number) => { + setMatches(width >= minWidth) + } + + update(element.getBoundingClientRect().width) + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + update(entry.contentRect.width) + }) + + observer.observe(element) + return () => observer.disconnect() + }, [minWidth, ref]) + + return matches +} diff --git a/src/pages/ocotillo/thing/well-show.tsx b/src/pages/ocotillo/thing/well-show.tsx index 044ed10a..092b3092 100644 --- a/src/pages/ocotillo/thing/well-show.tsx +++ b/src/pages/ocotillo/thing/well-show.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useDataProvider, useList, useResourceParams } from '@refinedev/core' import { captureEvent } from '@/analytics/posthog' import { useQuery } from '@tanstack/react-query' @@ -20,15 +20,16 @@ import { ISensor, IWellScreen, } from '@/interfaces/ocotillo' -import { Stack } from '@mui/material' +import { Box, Stack } from '@mui/material' import { IHydrographDatasource } from '@/interfaces/st2' import { useAccessCapabilities, + useContainerMinWidth, useSensorDeploymentRows, useSidebarPanelSync, + WELL_SHOW_TWO_COLUMN_MIN_PX, useWellDetails, } from '@/hooks' -import Grid from '@mui/material/Grid2' import { CoreWellInfoCard, InteractiveSatelliteMapCard, @@ -332,6 +333,120 @@ export const WellShow = () => { return source }, [manualHydrographRows, transducerHydrographRows]) + const layoutRef = useRef(null) + const isWideLayout = useContainerMinWidth( + layoutRef, + WELL_SHOW_TWO_COLUMN_MIN_PX + ) + + const wellShowCards = useMemo(() => { + const mainCards = [ + , + , + , + , + , + , + , + , + , + , + , + ] + + const sideCards = [ + , + , + , + , + , + ] + + const mobileCards = [ + mainCards[0], + sideCards[0], + sideCards[1], + mainCards[1], + mainCards[2], + mainCards[3], + sideCards[2], + mainCards[4], + mainCards[5], + mainCards[6], + mainCards[7], + mainCards[8], + mainCards[9], + mainCards[10], + sideCards[3], + sideCards[4], + ] + + return { mainCards, sideCards, mobileCards } + }, [ + assetQuery.isLoading, + assetQuery.refetch, + assets, + contacts, + deployments, + detailsQuery.isPending, + fieldEvents, + firstVisitParticipants, + hydrographDatasource, + hydrographQuery.isPending, + id, + idLinkDataGridProps, + isDetailsLoading, + manualHydrographRows, + osepod_id, + recentObservations, + sensors, + transducerHydrographRows, + usgs_id, + well, + wellScreens, + ]) + return ( { )} > - - - {/* Left column: 8 cols */} - - - - - - - - - - - - - + + {isWideLayout ? ( + + + {wellShowCards.mainCards} - - - {/* Right column: 2 cols */} - - - - - - - + + {wellShowCards.sideCards} - - - + + ) : ( + {wellShowCards.mobileCards} + )} + ) diff --git a/src/test/pages/well-show.test.tsx b/src/test/pages/well-show.test.tsx index 20f3cd0d..3ceff10f 100644 --- a/src/test/pages/well-show.test.tsx +++ b/src/test/pages/well-show.test.tsx @@ -45,6 +45,8 @@ vi.mock('@/hooks', () => ({ closePanel: vi.fn(), togglePanel: vi.fn(), }), + useContainerMinWidth: () => false, + WELL_SHOW_TWO_COLUMN_MIN_PX: 880, useWellDetails: (id: unknown) => { mockedUseWellDetails(id) return { From 938603f822c7802113627fbf38e3187e60346eb8 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 22 Jun 2026 17:08:43 -0600 Subject: [PATCH 2/6] Add canonical breakpoints, panel mutex, and responsive shell tweaks. Documents SCREENS scale in constants, Tailwind, and MUI. Get Help goes full-screen on phones, closes edit panel when opened, and well edit closes Get Help. Header actions wrap with better touch targets. --- src/components/AppShell.tsx | 83 ++++++++++++++++++-------- src/components/OcotilloPageHeader.tsx | 13 +++- src/components/SupportPanelContext.ts | 9 ++- src/components/card/CoreWellInfo.tsx | 7 +-- src/components/ui/sidebar.tsx | 6 +- src/constants/breakpoints.ts | 48 +++++++++++++++ src/hooks/use-mobile.ts | 9 +-- src/hooks/useContainerMinWidth.ts | 3 - src/index.css | 11 ++++ src/pages/ocotillo/thing/well-show.tsx | 23 +++++-- src/test/pages/well-show.test.tsx | 1 - src/theme.ts | 4 ++ 12 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 src/constants/breakpoints.ts diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index dacb5d09..601daf05 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,5 +1,6 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' +import { useIsMobile } from '@/hooks/use-mobile' import { Outlet, Link, useLocation, useNavigate } from 'react-router' import { CanAccess, @@ -594,6 +595,7 @@ function getBrowser(): string { function SupportPanel() { const { isOpen, close } = useContext(SupportPanelContext) + const isMobile = useIsMobile() const { data: user } = useGetIdentity<{ name: string; email: string }>() const location = useLocation() @@ -722,23 +724,12 @@ function SupportPanel() { const pageUrl = location.pathname - return ( + const panelBody = (
- {/* Drag handle */} -
- -
{/* Panel header */}
@@ -1017,6 +1008,36 @@ function SupportPanel() { )}
+ ) + + if (isMobile) { + if (!isOpen) return null + + return ( +
+ {panelBody} +
+ ) + } + + return ( +
+
+ {panelBody}
) } @@ -1146,14 +1167,14 @@ function ShellHeader() {
- {/* Search bar — hidden on mobile, visible sm+ */} -
+ {/* Search bar — hidden on mobile, visible tablet+ */} +
{/* Mobile search icon */} @@ -1246,10 +1266,18 @@ function SidebarAutoCollapse() { function AppShellInner({ children }: { children?: React.ReactNode }) { const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar() const [panelOpen, setPanelOpen] = useState(false) - // Remember whether the sidebar was open when the panel was triggered const sidebarWasOpen = useRef(false) + const secondaryPanelCloseRef = useRef<(() => void) | null>(null) + + const registerSecondaryPanelClose = useCallback( + (close: (() => void) | null) => { + secondaryPanelCloseRef.current = close + }, + [] + ) const openPanel = () => { + secondaryPanelCloseRef.current?.() sidebarWasOpen.current = sidebarOpen setSidebarOpen(false) setPanelOpen(true) @@ -1261,7 +1289,14 @@ function AppShellInner({ children }: { children?: React.ReactNode }) { } return ( - + diff --git a/src/components/OcotilloPageHeader.tsx b/src/components/OcotilloPageHeader.tsx index db796c39..d78d5e3c 100644 --- a/src/components/OcotilloPageHeader.tsx +++ b/src/components/OcotilloPageHeader.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { Box, Skeleton, SxProps, Theme, Typography } from '@mui/material' +import { cn } from '@/lib/utils' /** Shared CardHeader layout for Ocotillo list and show pages. */ export const ocotilloCardHeaderProps: { sx: SxProps } = { @@ -9,11 +10,13 @@ export const ocotilloCardHeaderProps: { sx: SxProps } = { gap: { xs: 1.5, md: 3 }, '.MuiCardHeader-content': { alignSelf: 'flex-start', + minWidth: 0, }, '.MuiCardHeader-action': { alignSelf: { xs: 'flex-end', md: 'flex-start' }, mr: 0, pt: { xs: 0.5, md: 1 }, + maxWidth: '100%', }, }, } @@ -69,6 +72,14 @@ export function OcotilloHeaderButtons({ className?: string }) { return ( -
{children}
+
+ {children} +
) } diff --git a/src/components/SupportPanelContext.ts b/src/components/SupportPanelContext.ts index b4ce2412..7ba14b3f 100644 --- a/src/components/SupportPanelContext.ts +++ b/src/components/SupportPanelContext.ts @@ -4,4 +4,11 @@ export const SupportPanelContext = createContext<{ isOpen: boolean open: () => void close: () => void -}>({ isOpen: false, open: () => {}, close: () => {} }) + /** Page-level panels (e.g. well edit) register a close handler for panel mutex. */ + registerSecondaryPanelClose: (close: (() => void) | null) => void +}>({ + isOpen: false, + open: () => {}, + close: () => {}, + registerSecondaryPanelClose: () => {}, +}) diff --git a/src/components/card/CoreWellInfo.tsx b/src/components/card/CoreWellInfo.tsx index 6639fdcf..cd139dab 100644 --- a/src/components/card/CoreWellInfo.tsx +++ b/src/components/card/CoreWellInfo.tsx @@ -1,15 +1,14 @@ import { Box, Paper, Skeleton, Typography } from '@mui/material' +import { CORE_WELL_INFO_STATS_MIN_PX } from '@/constants/breakpoints' import { IWell } from '@/interfaces/ocotillo' -const STATS_WIDE_MIN_PX = 480 - const statCellSx = (index: number) => ({ px: 2, py: 1.5, borderColor: 'divider', borderTop: index > 0 ? '1px solid' : 'none', borderLeft: 'none', - [`@container (min-width: ${STATS_WIDE_MIN_PX}px)`]: { + [`@container (min-width: ${CORE_WELL_INFO_STATS_MIN_PX}px)`]: { borderTop: 'none', borderLeft: index > 0 ? '1px solid' : 'none', }, @@ -18,7 +17,7 @@ const statCellSx = (index: number) => ({ const statsGridSx = { display: 'grid', gridTemplateColumns: '1fr', - [`@container (min-width: ${STATS_WIDE_MIN_PX}px)`]: { + [`@container (min-width: ${CORE_WELL_INFO_STATS_MIN_PX}px)`]: { gridTemplateColumns: 'repeat(3, 1fr)', }, } as const diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 64c84189..9048df98 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -187,7 +187,7 @@ function Sidebar({ return (