Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add location search history #493

Merged
merged 3 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions src/components/search/__tests__/search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import userEvent from '@testing-library/user-event';
import { SWRConfig } from 'swr';
import React from 'react';
import SwapButton from '../swap-button';
import GeolocationButton from '../geolocation-button';

const result = [
{
Expand Down Expand Up @@ -212,20 +211,19 @@ describe('search box', () => {
});

it('should call getCurrentPosition when geolocating', async () => {
customRender(
<Search
label="Test"
placeholder="Test"
onChange={() => {}}
button={<GeolocationButton onGeolocate={() => {}} />}
/>,
);
const fn = vi.fn();
customRender(<Search label="Test" placeholder="Test" onChange={fn} />);

const geolocationButton = screen.getByRole('button', {
name: 'Finn min posisjon',
// Set focus to open the dropdown
const input = screen.getByRole('textbox', {
name: /test/i,
});
await userEvent.click(input);

await userEvent.click(geolocationButton);
const geolocationOption = screen.getByRole('option', {
name: 'Min posisjon',
});
await userEvent.click(geolocationOption);

expect(mockGeolocation.getCurrentPosition).toHaveBeenCalled();
});
Expand Down
1 change: 0 additions & 1 deletion src/components/search/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export { default } from './search';
export { default as GeolocationButton } from './geolocation-button';
export { default as SwapButton } from './swap-button';
export { default as ClearButton } from './clear-button';
3 changes: 3 additions & 0 deletions src/components/search/search.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
overflow-y: auto;
z-index: 100;
}
.menuHeading {
padding-left: token('spacing.small');
}

.item {
padding: token('spacing.small');
Expand Down
115 changes: 110 additions & 5 deletions src/components/search/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import { andIf } from '@atb/utils/css';
import { GeocoderFeature } from '@atb/page-modules/departures';
import { logSpecificEvent } from '@atb/modules/firebase';
import { ComponentText, useTranslation } from '@atb/translations';
import useLocalStorage from '@atb/utils/use-localstorage';
import { Typo } from '../typography';
import { useGeolocation } from './use-geolocation';
import { MonoIcon } from '../icon';
import { LoadingIcon } from '../loading';

type SearchProps = {
label: string;
placeholder: string;
onChange: (selection: any) => void;
onChange: (selection: GeocoderFeature) => void;
onGeolocationError?: (error: string | null) => void;
button?: ReactNode;
initialFeature?: GeocoderFeature;
selectedItem?: GeocoderFeature;
Expand All @@ -23,21 +29,41 @@ export default function Search({
label,
placeholder,
onChange,
onGeolocationError,
button,
initialFeature,
selectedItem,
autocompleteFocusPoint,
testID,
}: SearchProps) {
const [query, setQuery] = useState('');
const [focus, setFocus] = useState(false);
const { data } = useAutocomplete(query, autocompleteFocusPoint);
const { t } = useTranslation();
const [recentFeatureSearches, setRecentFeatureSearches] = useLocalStorage<
GeocoderFeature[]
>('recentFeatureSearches', []);
const {
getPosition,
isLoading: isGeolocationLoading,
isUnavailable: isGeolocationUnavailable,
isError: isGeolocationError,
} = useGeolocation(onChange, onGeolocationError);

function getA11yStatusMessage({
isOpen,
resultCount,
previousResultCount,
}: A11yStatusMessageOptions<GeocoderFeature>) {
inputValue,
}: A11yStatusMessageOptions<GeocoderFeature | 'location'>) {
if (focus && inputValue === '') {
return t(
ComponentText.SearchInput.previousResultA11yLabel(
recentFeatureSearches.length,
),
);
}

if (!isOpen) {
return '';
}
Expand All @@ -53,13 +79,27 @@ export default function Search({
return '';
}

const handleOnChange = (feature: GeocoderFeature | 'location' | null) => {
if (!feature) return;
if (feature === 'location') {
getPosition();
return;
}
// Add to recent searches, or move to top of list if already in list
setRecentFeatureSearches([
feature,
...recentFeatureSearches.filter((f) => f.id !== feature.id).splice(0, 4),
]);
onChange(feature);
};

return (
<Downshift<GeocoderFeature>
<Downshift<GeocoderFeature | 'location'>
onInputValueChange={(inputValue) => {
logSpecificEvent('select_search');
return setQuery(inputValue || '');
}}
onChange={onChange}
onChange={handleOnChange}
itemToString={geocoderFeatureToString}
selectedItem={selectedItem || initialFeature || null}
getA11yStatusMessage={getA11yStatusMessage}
Expand Down Expand Up @@ -89,13 +129,16 @@ export default function Search({
placeholder={placeholder}
{...getInputProps()}
data-testid={testID}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</div>

{button ?? null}

<ul className={style.menu} {...getMenuProps()}>
{isOpen &&
inputValue !== '' &&
data?.map((item, index) => (
<li
className={andIf({
Expand All @@ -116,6 +159,64 @@ export default function Search({
<span className={style.itemLocality}>{item.locality}</span>
</li>
))}
{focus && inputValue === '' && (
<>
<li
className={andIf({
[style.item]: true,
[style.itemHighlighted]: highlightedIndex === 0,
})}
{...getItemProps({
index: 0,
item: 'location',
})}
data-testid={`list-item-0`}
>
<div className={style.itemIcon} aria-hidden>
{isGeolocationLoading ? (
<LoadingIcon />
) : (
<MonoIcon icon="places/Location" />
)}
</div>
<span className={style.itemName}>
{isGeolocationUnavailable || isGeolocationError
? t(ComponentText.SearchInput.positionNotAvailable)
: t(ComponentText.SearchInput.myPosition)}
</span>
</li>
{recentFeatureSearches.length > 0 && (
<li className={style.item}>
<Typo.span
textType="body__secondary"
className={style.menuHeading}
>
{t(ComponentText.SearchInput.recentSearches)}
</Typo.span>
</li>
)}
{recentFeatureSearches.map((item, index) => (
<li
className={andIf({
[style.item]: true,
[style.itemHighlighted]: highlightedIndex === index + 1,
})}
key={item.id}
{...getItemProps({
index: index + 1,
item,
})}
data-testid={`list-item-${index + 1}`}
>
<div className={style.itemIcon} aria-hidden>
<VenueIcon categories={item.category} />
</div>
<span className={style.itemName}>{item.name}</span>
<span className={style.itemLocality}>{item.locality}</span>
</li>
))}
</>
)}
</ul>
</div>
)}
Expand All @@ -124,8 +225,12 @@ export default function Search({
}

function geocoderFeatureToString(
feature: GeocoderFeature | null | undefined,
feature: GeocoderFeature | 'location' | null | undefined,
): string {
if (feature === 'location') {
// Location has been selected, but it hasn't been resolved to a feature yet
return '';
}
return feature
? `${feature.name}${feature.locality ? ', ' + feature.locality : ''}`
: '';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,52 +1,15 @@
import { Component, useEffect, useState } from 'react';
import { LoadingIcon } from '@atb/components/loading';
import { useEffect, useState } from 'react';
import { reverse } from '@atb/page-modules/departures/client';
import { GeocoderFeature } from '@atb/page-modules/departures';
import {
ComponentText,
TranslateFunction,
useTranslation,
} from '@atb/translations';
import { MonoIcon } from '@atb/components/icon';

type GeolocationButtonProps = {
onGeolocate: (feature: GeocoderFeature) => void;
onError?: (error: string | null) => void;
className?: string;
};
function GeolocationButton({
onGeolocate,
onError,
className,
}: GeolocationButtonProps) {
const { t } = useTranslation();
const { getPosition, isLoading, isUnavailable } = useGeolocation(
onGeolocate,
onError,
);

if (isUnavailable) return null;

return isLoading ? (
<div className={className}>
<LoadingIcon a11yText={t(ComponentText.GeolocationButton.loading)} />
</div>
) : (
<button
className={className}
onClick={getPosition}
title={t(ComponentText.GeolocationButton.alt)}
aria-label={t(ComponentText.GeolocationButton.alt)}
type="button"
>
<MonoIcon icon="places/Location" />
</button>
);
}

function useGeolocation(
export function useGeolocation(
onSuccess: (feature: GeocoderFeature) => void,
onError: (error: string | null) => void = () => { },
onError: (error: string | null) => void = () => {},
) {
const { t } = useTranslation();
const [error, setError] = useState<GeolocationPositionError | null>(null);
Expand Down Expand Up @@ -82,12 +45,11 @@ function useGeolocation(
getPosition,
error,
isLoading,
isError: !!error,
isUnavailable,
};
}

export default GeolocationButton;

function getErrorMessage(code: number, t: TranslateFunction) {
switch (code) {
case GeolocationPositionError.PERMISSION_DENIED:
Expand Down
16 changes: 4 additions & 12 deletions src/page-modules/assistant/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import { Button, ButtonLink } from '@atb/components/button';
import { MonoIcon } from '@atb/components/icon';
import EmptySearch from '@atb/components/loading-empty-results';
import { MessageBox } from '@atb/components/message-box';
import Search, {
ClearButton,
GeolocationButton,
SwapButton,
} from '@atb/components/search';
import Search, { ClearButton, SwapButton } from '@atb/components/search';
import { Typo } from '@atb/components/typography';
import type { SearchTime } from '@atb/modules/search-time';
import SearchTimeSelector from '@atb/modules/search-time/selector';
Expand Down Expand Up @@ -160,13 +156,7 @@ function AssistantLayout({ children, tripQuery }: AssistantLayoutProps) {
onChange={onFromSelected}
selectedItem={tripQuery.from ?? undefined}
testID="searchFrom"
button={
<GeolocationButton
className={style.searchInputButton}
onGeolocate={onFromSelected}
onError={setGeolocationError}
/>
}
onGeolocationError={setGeolocationError}
/>
<Search
label={t(PageText.Assistant.search.input.to)}
Expand All @@ -182,6 +172,7 @@ function AssistantLayout({ children, tripQuery }: AssistantLayoutProps) {
/>
}
autocompleteFocusPoint={tripQuery.from ?? undefined}
onGeolocationError={setGeolocationError}
/>
</div>
<div className={style.date}>
Expand Down Expand Up @@ -245,6 +236,7 @@ function AssistantLayout({ children, tripQuery }: AssistantLayoutProps) {
/>
)
}
onGeolocationError={setGeolocationError}
/>
</div>

Expand Down
11 changes: 2 additions & 9 deletions src/page-modules/departures/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Button, ButtonLink } from '@atb/components/button';
import { Button } from '@atb/components/button';
import LoadingEmptySearch from '@atb/components/loading-empty-results';
import { MessageBox } from '@atb/components/message-box';
import Search from '@atb/components/search';
import GeolocationButton from '@atb/components/search/geolocation-button';
import { Typo } from '@atb/components/typography';
import { SearchTime, SearchTimeSelector } from '@atb/modules/search-time';
import type { GeocoderFeature } from '@atb/page-modules/departures';
Expand Down Expand Up @@ -66,13 +65,7 @@ function DeparturesLayout({ children, fromQuery }: DeparturesLayoutProps) {
selectedItem={fromQuery.from ?? undefined}
onChange={onSelectFeature}
testID="searchFrom"
button={
<GeolocationButton
className={style.geolocationButton}
onGeolocate={onSelectFeature}
onError={setGeolocationError}
/>
}
onGeolocationError={setGeolocationError}
/>
</div>

Expand Down
Loading