diff --git a/__tests__/components/viewers/__snapshots__/nearby-view.js.snap b/__tests__/components/viewers/__snapshots__/nearby-view.js.snap index 65c9368a3..1a5af6b06 100644 --- a/__tests__/components/viewers/__snapshots__/nearby-view.js.snap +++ b/__tests__/components/viewers/__snapshots__/nearby-view.js.snap @@ -57,38 +57,47 @@ exports[`components > viewers > nearby view renders nothing on a blank page 1`] title="components.NearbyView.header" /> -

- - components.NearbyView.nearbyListIntro - -

+ + components.NearbyView.nearbyListIntro + + +
    @@ -4091,38 +4100,47 @@ exports[`components > viewers > nearby view renders proper scooter dates 1`] = ` title="components.NearbyView.header" /> -

    - - components.NearbyView.nearbyListIntro - -

    + + components.NearbyView.nearbyListIntro + + +
      diff --git a/lib/components/app/responsive-webapp.js b/lib/components/app/responsive-webapp.js index ee4c3c684..c2e11360c 100644 --- a/lib/components/app/responsive-webapp.js +++ b/lib/components/app/responsive-webapp.js @@ -55,6 +55,24 @@ class ResponsiveWebapp extends Component { /** Lifecycle methods **/ + // Check if the position has changed enough to update the currentPosition + // (prevent constant updates in nearby view) + // .001 works out to be about 94-300 ft depending on the longitude. + positionShouldUpdate = (position) => { + const { currentPosition } = this.props + if (!currentPosition.coords) return true + const latChanged = + Math.abs( + position?.coords?.latitude - currentPosition?.coords?.latitude + ) >= 0.001 + const lonChanged = + Math.abs( + position?.coords?.longitude - currentPosition?.coords?.longitude + ) >= 0.001 + + return latChanged || lonChanged + } + /* eslint-disable-next-line complexity */ componentDidUpdate(prevProps) { const { @@ -69,6 +87,7 @@ class ResponsiveWebapp extends Component { map, matchContentToUrl, query, + receivedPositionResponse, setLocationToCurrent, setMapCenter } = this.props @@ -119,6 +138,25 @@ class ResponsiveWebapp extends Component { } } + // Watch for position changing on mobile + if (isMobile()) { + navigator.geolocation.watchPosition( + // On success + (position) => { + const shouldUpdate = this.positionShouldUpdate(position) + if (shouldUpdate) { + receivedPositionResponse({ position }) + } + }, + // On error + (error) => { + console.log('error in watchPosition', error) + }, + // Options + { enableHighAccuracy: true } + ) + } + // If the path changes (e.g., via a back button press) check whether the // main content needs to switch between, for example, a viewer and a search. if (!isEqual(location.pathname, prevProps.location.pathname)) { @@ -154,7 +192,6 @@ class ResponsiveWebapp extends Component { map, matchContentToUrl, parseUrlQueryString, - receivedPositionResponse, setNetworkConnectionLost } = this.props // Add on back button press behavior. @@ -179,19 +216,6 @@ class ResponsiveWebapp extends Component { if (isMobile()) { // Test location availability on load getCurrentPosition(intl) - // Also, watch for changes in position on mobile - navigator.geolocation.watchPosition( - // On success - (position) => { - receivedPositionResponse({ position }) - }, - // On error - (error) => { - console.log('error in watchPosition', error) - }, - // Options - { enableHighAccuracy: true } - ) } // Handle routing to a specific part of the app (e.g. stop viewer) on page // load. (This happens prior to routing request in case special routerId is @@ -426,10 +450,12 @@ class RouterWrapperWithAuth0 extends Component { } const mapStateToWrapperProps = (state) => { - const { homeTimezone, map, persistence, reactRouter } = state.otp.config + const { homeTimezone, location, map, persistence, reactRouter } = + state.otp.config return { auth0Config: getAuth0Config(persistence), autoFly: map.autoFlyOnTripFormUpdate, + currentPosition: location?.currentPosition, defaultLocale: getDefaultLocale(state.otp.config, state.user.loggedInUser), homeTimezone, locale: state.otp.ui.locale, diff --git a/lib/components/form/call-taker/date-time-picker.tsx b/lib/components/form/call-taker/date-time-picker.tsx index 96292df86..47ae53ba7 100644 --- a/lib/components/form/call-taker/date-time-picker.tsx +++ b/lib/components/form/call-taker/date-time-picker.tsx @@ -5,7 +5,13 @@ import { IntlShape, useIntl } from 'react-intl' import { isMatch, parse } from 'date-fns' import { OverlayTrigger, Tooltip } from 'react-bootstrap' import coreUtils from '@opentripplanner/core-utils' -import React, { useEffect, useRef, useState } from 'react' +import React, { + ChangeEvent, + useCallback, + useEffect, + useRef, + useState +} from 'react' import { AppReduxState, FilterType, SortType } from '../../../util/state-types' import { DepartArriveTypeMap, DepartArriveValue } from '../date-time-modal' @@ -69,6 +75,30 @@ const safeFormat = (date: Date | '', time: string, options?: OptionsWithTZ) => { } return '' } +/** + * Parse a time input expressed in the agency time zone. + * @returns A date if the parsing succeeded, or null. + */ +const parseInputAsTime = ( + homeTimezone: string, + timeInput: string = getCurrentTime(homeTimezone), + date: string = getCurrentDate(homeTimezone) +) => { + if (!timeInput) timeInput = getCurrentTime(homeTimezone) + + // Match one of the supported time formats + const matchedTimeFormat = SUPPORTED_TIME_FORMATS.find((timeFormat) => + isMatch(timeInput, timeFormat) + ) + if (matchedTimeFormat) { + const resolvedDateTime = format( + parse(timeInput, matchedTimeFormat, new Date()), + 'HH:mm:ss' + ) + return toDate(`${date}T${resolvedDateTime}`) + } + return '' +} type Props = { date?: string @@ -121,69 +151,36 @@ const DateTimeOptions = ({ ) const [date, setDate] = useState(initialDate) const [time, setTime] = useState(initialTime) - const [typedTime, setTypedTime] = useState(initialTime) + const [typedTime, setTypedTime] = useState( + safeFormat(parseInputAsTime(homeTimezone, time, date), timeFormat, { + timeZone: homeTimezone + }) + ) const timeRef = useRef(null) const intl = useIntl() - /** - * Parse a time input expressed in the agency time zone. - * @returns A date if the parsing succeeded, or null. - */ - const parseInputAsTime = ( - timeInput: string = getCurrentTime(homeTimezone), - date: string = getCurrentDate(homeTimezone) - ) => { - if (!timeInput) timeInput = getCurrentTime(homeTimezone) - - // Match one of the supported time formats - const matchedTimeFormat = SUPPORTED_TIME_FORMATS.find((timeFormat) => - isMatch(timeInput, timeFormat) - ) - if (matchedTimeFormat) { - const resolvedDateTime = format( - parse(timeInput, matchedTimeFormat, new Date()), - 'HH:mm:ss' - ) - return toDate(`${date}T${resolvedDateTime}`) - } - return '' - } - - const dateTime = parseInputAsTime(time, date) + const dateTime = parseInputAsTime(homeTimezone, time, date) // Update state when external state is updated useEffect(() => { if (initialDate !== date) setDate(initialDate) if (initialTime !== time) { - setTime(initialTime) + handleTimeChange(initialTime || '') } + // This effect is design to flow from state to component only + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialTime, initialDate]) - useEffect(() => { - // Don't update if still typing - if (timeRef.current !== document.activeElement) { - setTypedTime( - safeFormat(dateTime, timeFormat, { - timeZone: homeTimezone - }) || - // TODO: there doesn't seem to be an intl object present? - 'Invalid Time' - ) - } - }, [time]) - useEffect(() => { if (initialDepartArrive && departArrive !== initialDepartArrive) { setDepartArrive(initialDepartArrive) } + // This effect is design to flow from state to component only + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialDepartArrive]) - useEffect(() => { - if (departArrive === 'NOW') setTypedTime('') - }, [departArrive]) - // Handler for setting the query parameters useEffect(() => { if (safeFormat(dateTime, OTP_API_DATE_FORMAT, {}) !== '' && setQueryParam) { @@ -197,42 +194,64 @@ const DateTimeOptions = ({ }) }) } - - if ( - syncSortWithDepartArrive && - DepartArriveTypeMap[departArrive] !== sort.type - ) { - importedUpdateItineraryFilter({ - sort: { - ...sort, - type: DepartArriveTypeMap[departArrive] - } - }) - } }, [dateTime, departArrive, homeTimezone, setQueryParam]) - // Handler for updating the time and date fields when NOW is selected - useEffect(() => { - if (departArrive === 'NOW') { - setTime(getCurrentTime(homeTimezone)) - setDate(getCurrentDate(homeTimezone)) - setTypedTime( - safeFormat(dateTime, timeFormat, { - timeZone: homeTimezone + const handleDepartArriveChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value as DepartArriveValue + setDepartArrive(newValue) + + // Handler for updating the time and date fields when NOW is selected + if (newValue === 'NOW') { + handleTimeChange(getCurrentTime(homeTimezone)) + setDate(getCurrentDate(homeTimezone)) + setTypedTime( + safeFormat(dateTime, timeFormat, { + timeZone: homeTimezone + }) + ) + } + + // Update sort type if needed + if ( + syncSortWithDepartArrive && + DepartArriveTypeMap[newValue] !== sort.type + ) { + importedUpdateItineraryFilter({ + sort: { + ...sort, + type: DepartArriveTypeMap[newValue] + } }) - ) - } - }, [departArrive, setTime, setDate, homeTimezone]) + } + }, + [syncSortWithDepartArrive, sort, importedUpdateItineraryFilter] + ) - const unsetNow = () => { + const unsetNow = useCallback(() => { if (departArrive === 'NOW') setDepartArrive('DEPART') - } + }, [departArrive]) + + const handleTimeChange = useCallback( + (newTime: string) => { + setTime(newTime) + // Only update typed time if not actively typing + if (timeRef.current !== document.activeElement) { + setTypedTime( + safeFormat(dateTime, timeFormat, { + timeZone: homeTimezone + }) || 'Invalid Time' + ) + } + }, + [dateTime, timeFormat, homeTimezone] + ) return ( <> { - setTime(e.target.value) - setTypedTime(e.target.value) - unsetNow() - }} + onChange={useCallback( + (e) => { + handleTimeChange(e.target.value) + setTypedTime(e.target.value) + unsetNow() + }, + [handleTimeChange, setTypedTime, unsetNow] + )} onFocus={(e) => e.target.select()} onKeyDown={onKeyDown} ref={timeRef} @@ -278,15 +300,18 @@ const DateTimeOptions = ({ { - if (!e.target.value) { - e.preventDefault() - // TODO: prevent selection from advancing to next field - return - } - setDate(e.target.value) - unsetNow() - }} + onChange={useCallback( + (e) => { + if (!e.target.value) { + e.preventDefault() + // TODO: prevent selection from advancing to next field + return + } + setDate(e.target.value) + unsetNow() + }, + [unsetNow, setDate] + )} onKeyDown={onKeyDown} style={{ fontSize: '14px', diff --git a/lib/components/mobile/navigation-bar.js b/lib/components/mobile/navigation-bar.js index 1e7b0079c..a31e75d02 100644 --- a/lib/components/mobile/navigation-bar.js +++ b/lib/components/mobile/navigation-bar.js @@ -64,7 +64,7 @@ class MobileNavigationBar extends Component { const backButtonText = intl.formatMessage({ id: 'common.forms.back' }) return ( -
      +
      diff --git a/lib/components/viewers/nearby/nearby-view.tsx b/lib/components/viewers/nearby/nearby-view.tsx index 1405f9eae..9c60ff677 100644 --- a/lib/components/viewers/nearby/nearby-view.tsx +++ b/lib/components/viewers/nearby/nearby-view.tsx @@ -22,6 +22,7 @@ import { Scrollable } from './styled' import FromToPicker from './from-to-picker' +import InvisibleA11yLabel from '../../util/invisible-a11y-label' import RentalStation from './rental-station' import Stop from './stop' import Vehicle from './vehicle-rent' @@ -273,16 +274,16 @@ function NearbyView({ /> )} {nearby && ( -

      + -

      + )} {/* This is used to scroll to top */}
      diff --git a/lib/components/viewers/nearby/styled.tsx b/lib/components/viewers/nearby/styled.tsx index d088ebbfe..8ee6bef6e 100644 --- a/lib/components/viewers/nearby/styled.tsx +++ b/lib/components/viewers/nearby/styled.tsx @@ -18,6 +18,10 @@ export const NearbySidebarContainer = styled.ol` gap: 1em; padding: 0 1em; list-style: none; + + @media (max-width: 768px) { + min-height: calc(100vh - 50px); + } ` export const Card = styled.div`