Skip to content
53 changes: 53 additions & 0 deletions src/pages/components/StickyInputs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { styled } from '@mui/material/styles'
import { type ReactNode, useEffect, useRef, useState } from 'react'

// Pinned filter section. Transparent at rest (matches the pre-PR baseline —
// no visible container box on the gaps page); becomes opaque using the MUI
// theme's default page background only once the scrollable ancestor has
// actually scrolled, which is exactly when content would otherwise bleed
// through. This avoids the visual regression Noam flagged while preserving
// the bleed-through fix earlier in #1528.
const Container = styled('div')(({ theme }) => ({
position: 'sticky',
top: 0,
zIndex: 100,
paddingBottom: 8,
'&[data-stuck="true"]': {
backgroundColor: theme.palette.background.default,
},
}))

function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
let parent: HTMLElement | null = el.parentElement
while (parent) {
const { overflowY } = getComputedStyle(parent)
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent
}
parent = parent.parentElement
}
return null
}

export const StickyInputs = ({ children }: { children: ReactNode }) => {
const ref = useRef<HTMLDivElement>(null)
const [stuck, setStuck] = useState(false)

useEffect(() => {
const el = ref.current
if (!el) return
const scrollAncestor = findScrollableAncestor(el)
const target: HTMLElement | Window = scrollAncestor ?? window
const getScroll = () => (scrollAncestor ? scrollAncestor.scrollTop : window.scrollY)
const check = () => setStuck(getScroll() > 0)
check()
target.addEventListener('scroll', check, { passive: true })
return () => target.removeEventListener('scroll', check)
}, [])

return (
<Container ref={ref} data-stuck={stuck ? 'true' : 'false'}>
{children}
</Container>
)
}
99 changes: 51 additions & 48 deletions src/pages/gaps/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import OperatorSelector from '../components/OperatorSelector'
import { PageContainer } from '../components/PageContainer'
import RouteSelector from '../components/RouteSelector'
import { Row } from '../components/Row'
import { StickyInputs } from '../components/StickyInputs'
import GapsTable from './GapsTable'

const GapsPage = () => {
Expand Down Expand Up @@ -119,56 +120,58 @@ const GapsPage = () => {
<Alert severity="info" variant="outlined" icon={false}>
{t('gaps_page_description')}
</Alert>
<Grid container spacing={2} sx={{ maxWidth: INPUT_SIZE }}>
{/* choose date */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_date')} />
</Grid>
<Grid size={{ xs: 8 }}>
<DateSelector time={dayjs.tz(date, ISRAEL_TIMEZONE)} onChange={handleDateChange} />
</Grid>
{/* choose operator */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_operator')} />
</Grid>
<Grid size={{ xs: 8 }}>
<OperatorSelector
operatorId={operatorId ?? undefined}
setOperatorId={handleOperatorChange}
/>
</Grid>
{/* choose line */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_line')} />
</Grid>
<Grid size={{ xs: 8 }}>
<LineNumberSelector
lineNumber={lineNumber ?? undefined}
setLineNumber={handleLineNumberChange}
/>
</Grid>
{/* choose routes */}
<Grid size={{ xs: 12 }}>
{routes?.length === 0 ? (
<NotFound>{t('line_not_found')}</NotFound>
) : (
<RouteSelector
routes={routes || []}
disabled={!routes}
routeKey={routeKey ?? undefined}
setRouteKey={handleRouteKeyChange}
<StickyInputs>
<Grid container spacing={2} sx={{ maxWidth: INPUT_SIZE }}>
{/* choose date */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_date')} />
</Grid>
<Grid size={{ xs: 8 }}>
<DateSelector time={dayjs.tz(date, ISRAEL_TIMEZONE)} onChange={handleDateChange} />
</Grid>
{/* choose operator */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_operator')} />
</Grid>
<Grid size={{ xs: 8 }}>
<OperatorSelector
operatorId={operatorId ?? undefined}
setOperatorId={handleOperatorChange}
/>
)}
</Grid>
<Grid size={{ xs: 12 }}>
{gapsIsLoading && (
<Row>
<Label text={t('loading_gaps')} />
<CircularProgress />
</Row>
)}
</Grid>
{/* choose line */}
<Grid size={{ xs: 4 }}>
<Label text={t('choose_line')} />
</Grid>
<Grid size={{ xs: 8 }}>
<LineNumberSelector
lineNumber={lineNumber ?? undefined}
setLineNumber={handleLineNumberChange}
/>
</Grid>
{/* choose routes */}
<Grid size={{ xs: 12 }}>
{routes?.length === 0 ? (
<NotFound>{t('line_not_found')}</NotFound>
) : (
<RouteSelector
routes={routes || []}
disabled={!routes}
routeKey={routeKey ?? undefined}
setRouteKey={handleRouteKeyChange}
/>
)}
</Grid>
<Grid size={{ xs: 12 }}>
{gapsIsLoading && (
<Row>
<Label text={t('loading_gaps')} />
<CircularProgress />
</Row>
)}
</Grid>
</Grid>
</Grid>
</StickyInputs>
{routeKey && routeKey !== '' && (
<GapsTable
loading={gapsIsLoading}
Expand Down
191 changes: 98 additions & 93 deletions src/pages/historicTimeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import LineNumberSelector from 'src/pages/components/LineSelector'
import OperatorSelector from 'src/pages/components/OperatorSelector'
import RouteSelector from 'src/pages/components/RouteSelector'
import { Row } from 'src/pages/components/Row'
import { StickyInputs } from 'src/pages/components/StickyInputs'

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import style for StickyInputs is inconsistent across pages: this file uses an absolute src/... import while src/pages/gaps/index.tsx uses a relative ../components/StickyInputs. Standardizing on one convention improves consistency and avoids path-alias-only builds causing import churn (and can prevent accidental duplicate module instances in some setups).

Suggested change
import { StickyInputs } from 'src/pages/components/StickyInputs'
import { StickyInputs } from '../components/StickyInputs'

Copilot uses AI. Check for mistakes.
import StopSelector from 'src/pages/components/StopSelector'
import { TimelineBoard } from 'src/pages/components/timeline/TimelineBoard'
import Widget from 'src/shared/Widget'
Expand Down Expand Up @@ -108,103 +109,107 @@ const TimelinePage = () => {
{t('no_data_from_ETL')}
</Alert>
)}
<Grid container spacing={2}>
{/* choose date */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<DateSelector
time={dayjs.tz(date, ISRAEL_TIMEZONE)}
onChange={(ts) => {
if (!ts) return
setSearch((current) => ({
...current,
date: ts.format('YYYY-MM-DD'),
}))
}}
/>
</Grid>
{/* choose time */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<TimeSelector
time={timeOfDay}
onChange={(ts) => {
if (!ts) return
setTimeOfDay(ts.startOf('minute'))
}}
/>
</Grid>
{/* choose operator */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<OperatorSelector
operatorId={operatorId ?? undefined}
setOperatorId={(id) => setSearch((current) => ({ ...current, operatorId: id }))}
/>
</Grid>
{/* choose line */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<LineNumberSelector
lineNumber={lineNumber ?? undefined}
setLineNumber={(number) => setSearch((current) => ({ ...current, lineNumber: number }))}
/>
</Grid>
{/* routes */}
<Grid container size={{ lg: 4, md: 6, xs: 12 }}>
<Row style={{ width: '100%' }}>
<div style={{ width: '100%' }}>
{routesQuery.data?.length === 0 ? (
<NotFound>{t('line_not_found')}</NotFound>
) : (
<RouteSelector
disabled={!routesQuery.data}
routes={routesQuery.data || []}
routeKey={routeKey ?? undefined}
setRouteKey={(key) =>
setSearch((current) => ({ ...current, routeKey: key ?? null }))
<StickyInputs>
<Grid container spacing={2}>
{/* choose date */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<DateSelector
time={dayjs.tz(date, ISRAEL_TIMEZONE)}
onChange={(ts) => {
if (!ts) return
setSearch((current) => ({
...current,
date: ts.format('YYYY-MM-DD'),
}))
}}
/>
</Grid>
{/* choose time */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<TimeSelector
time={timeOfDay}
onChange={(ts) => {
if (!ts) return
setTimeOfDay(ts.startOf('minute'))
}}
/>
</Grid>
{/* choose operator */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<OperatorSelector
operatorId={operatorId ?? undefined}
setOperatorId={(id) => setSearch((current) => ({ ...current, operatorId: id }))}
/>
</Grid>
{/* choose line */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<LineNumberSelector
lineNumber={lineNumber ?? undefined}
setLineNumber={(number) =>
setSearch((current) => ({ ...current, lineNumber: number }))
}
/>
</Grid>
{/* routes */}
<Grid container size={{ lg: 4, md: 6, xs: 12 }}>
<Row style={{ width: '100%' }}>
<div style={{ width: '100%' }}>
{routesQuery.data?.length === 0 ? (
<NotFound>{t('line_not_found')}</NotFound>
) : (
<RouteSelector
disabled={!routesQuery.data}
routes={routesQuery.data || []}
routeKey={routeKey ?? undefined}
setRouteKey={(key) =>
setSearch((current) => ({ ...current, routeKey: key ?? null }))
}
/>
)}
</div>
{routesQuery.isLoading && <CircularProgress />}
</Row>
</Grid>
{/* stops */}
<Grid container size={{ lg: 4, md: 6, xs: 12 }}>
<Row style={{ width: '100%' }}>
<div style={{ width: '100%' }}>
<StopSelector
disabled={!stopsQuery.data}
stops={stopsQuery.data || []}
stopKey={search.stopKey ?? undefined}
setStopKey={(key) =>
setSearch((current) => ({ ...current, stopKey: key ?? null }))
}
/>
)}
</div>
{routesQuery.isLoading && <CircularProgress />}
</Row>
</div>
{stopsQuery.isLoading && <CircularProgress />}
</Row>
</Grid>
</Grid>
{/* stops */}
<Grid container size={{ lg: 4, md: 6, xs: 12 }}>
<Row style={{ width: '100%' }}>
<div style={{ width: '100%' }}>
<StopSelector
disabled={!stopsQuery.data}
stops={stopsQuery.data || []}
stopKey={search.stopKey ?? undefined}
setStopKey={(key) => setSearch((current) => ({ ...current, stopKey: key ?? null }))}
</StickyInputs>
{/* hits timeline */}
{selectedRoute && selectedStop && (
<Widget marginBottom>
{hitsQuery.isLoading && (
<Row>
<Label text={t('loading_hits')} />
<CircularProgress />
</Row>
)}
{!hitsQuery.isLoading &&
((hitsQuery.data?.gtfsTime && hitsQuery.data.gtfsTime.length > 0) ||
(hitsQuery.data?.siriTime && hitsQuery.data.siriTime.length > 0) ? (
<TimelineBoard
target={time}
gtfsTimes={hitsQuery.data.gtfsTime}
siriTimes={hitsQuery.data.siriTime}
/>
</div>
{stopsQuery.isLoading && <CircularProgress />}
</Row>
</Grid>
{/* hits timeline */}
{selectedRoute && selectedStop && (
<Grid size={{ xs: 12 }}>
<Widget marginBottom>
{hitsQuery.isLoading && (
<Row>
<Label text={t('loading_hits')} />
<CircularProgress />
</Row>
)}
{!hitsQuery.isLoading &&
((hitsQuery.data?.gtfsTime && hitsQuery.data.gtfsTime.length > 0) ||
(hitsQuery.data?.siriTime && hitsQuery.data.siriTime.length > 0) ? (
<TimelineBoard
target={time}
gtfsTimes={hitsQuery.data.gtfsTime}
siriTimes={hitsQuery.data.siriTime}
/>
) : (
<NotFound>{t('hits_not_found')}</NotFound>
))}
</Widget>
</Grid>
)}
</Grid>
) : (
<NotFound>{t('hits_not_found')}</NotFound>
))}
</Widget>
)}
</PageContainer>
)
}
Expand Down
Loading