Skip to content
Open
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
58 changes: 51 additions & 7 deletions src/components/trip-planner/TripPlan.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
let loading = $state(false);
let fromRequestId = 0;
let toRequestId = 0;
let fromAutocompleteRequestId = 0;
let toAutocompleteRequestId = 0;
// Recent trips stay behind a compact control so the plan form doesn't grow
// a tall history list under From/To (see #577).
let showRecentTrips = $state(false);
Expand All @@ -56,19 +58,39 @@
return data.suggestions;
}

const fetchLocationResults = debounce(async (query, isFrom) => {
function invalidateAutocompleteRequest(isFrom) {
if (isFrom) {
return ++fromAutocompleteRequestId;
}

return ++toAutocompleteRequestId;
}

function isCurrentAutocompleteRequest(isFrom, requestId) {
return isFrom ? requestId === fromAutocompleteRequestId : requestId === toAutocompleteRequestId;
}

const fetchLocationResults = debounce(async (query, isFrom, requestId) => {
if (!isCurrentAutocompleteRequest(isFrom, requestId)) return;

isLoadingFrom = isFrom;
isLoadingTo = !isFrom;

try {
const results = await fetchAutocompleteResults(query);

if (!isCurrentAutocompleteRequest(isFrom, requestId)) return;

isFrom ? (fromResults = results) : (toResults = results);
} catch (error) {
console.error('Error fetching location results:', error);
if (isCurrentAutocompleteRequest(isFrom, requestId)) {
console.error('Error fetching location results:', error);
}
} finally {
isLoadingFrom = false;
isLoadingTo = false;
if (isCurrentAutocompleteRequest(isFrom, requestId)) {
isLoadingFrom = false;
isLoadingTo = false;
}
}
}, 500);

Expand All @@ -91,12 +113,18 @@
// (and the parent's hasPlanned flag) instead of letting "No itineraries
// found" linger under the form while the rider edits.
clearTripItineraries();
const requestId = invalidateAutocompleteRequest(isFrom);
if (query.trim() === '') {
if (isFrom) fromResults = [];
else toResults = [];
if (isFrom) {
fromResults = [];
isLoadingFrom = false;
} else {
toResults = [];
isLoadingTo = false;
}
return;
}
await fetchLocationResults(query, isFrom);
await fetchLocationResults(query, isFrom, requestId);
}

async function selectLocation(suggestion, isFrom) {
Expand Down Expand Up @@ -140,9 +168,11 @@
}

function clearInput(isFrom) {
invalidateAutocompleteRequest(isFrom);
if (isFrom) {
fromPlace = '';
fromResults = [];
isLoadingFrom = false;
selectedFrom = null;
if (fromMarker) {
mapProvider.removePinMarker(fromMarker);
Expand All @@ -151,6 +181,7 @@
} else {
toPlace = '';
toResults = [];
isLoadingTo = false;
selectedTo = null;
if (toMarker) {
mapProvider.removePinMarker(toMarker);
Expand All @@ -161,6 +192,17 @@
clearTripUrl();
}

function dismissSearchResults(isFrom) {
invalidateAutocompleteRequest(isFrom);
if (isFrom) {
fromResults = [];
isLoadingFrom = false;
} else {
toResults = [];
isLoadingTo = false;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function swapLocations() {
const result = swapTripLocations({
fromPlace,
Expand Down Expand Up @@ -443,6 +485,7 @@
onInput={(query) => handleSearchInput(query, true)}
onClear={() => clearInput(true)}
onSelect={(location) => selectLocation(location, true)}
onDismiss={() => dismissSearchResults(true)}
/>
</div>
</div>
Expand Down Expand Up @@ -471,6 +514,7 @@
onInput={(query) => handleSearchInput(query, false)}
onClear={() => clearInput(false)}
onSelect={(location) => selectLocation(location, false)}
onDismiss={() => dismissSearchResults(false)}
/>
</div>
</div>
Expand Down
67 changes: 63 additions & 4 deletions src/components/trip-planner/TripPlanSearchField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* @property {(value: string) => void} onInput
* @property {() => void} onClear
* @property {any} onSelect
* @property {() => void} [onDismiss]
*/

/** @type {Props} */
Expand All @@ -21,10 +22,16 @@
isLoading = false,
onInput,
onClear,
onSelect
onSelect,
onDismiss = () => {}
} = $props();

let activeIndex = $state(-1);
let listboxId = $derived(`${inputId}-listbox`);
let hasResults = $derived(!isLoading && Array.isArray(results) && results.length > 0);

function handleInput(event) {
activeIndex = -1;
onInput(event.target.value);
}

Expand All @@ -33,8 +40,40 @@
}

function handleSelect(result) {
activeIndex = -1;
onSelect(result);
}

function optionId(index) {
return `${listboxId}-option-${index}`;
}

function handleKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
activeIndex = -1;
onDismiss();
return;
}

if (!hasResults) return;

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
activeIndex = activeIndex < results.length - 1 ? activeIndex + 1 : 0;
break;
case 'ArrowUp':
event.preventDefault();
activeIndex = activeIndex > 0 ? activeIndex - 1 : results.length - 1;
break;
case 'Enter':
if (activeIndex < 0) return;
event.preventDefault();
handleSelect(results[activeIndex]);
break;
}
}
</script>

<div class="relative">
Expand All @@ -43,6 +82,13 @@
type="text"
bind:value={place}
oninput={handleInput}
onkeydown={handleKeydown}
role="combobox"
aria-autocomplete="list"
aria-expanded={hasResults}
aria-haspopup="listbox"
aria-controls={hasResults ? listboxId : undefined}
aria-activedescendant={hasResults && activeIndex >= 0 ? optionId(activeIndex) : undefined}
placeholder="{$t('trip-planner.search_for_a_place')}..."
class="block w-full rounded-md border-gray-300 pr-10 text-sm text-black shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
Expand All @@ -58,18 +104,31 @@
{/if}
{#if isLoading}
<p
role="status"
class="absolute z-10 mt-1 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 shadow-lg"
>
{$t('trip-planner.loading')}...
</p>
{:else if results && results.length > 0}
<ul
id={listboxId}
role="listbox"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
>
{#each results as result}
<li>
{#each results as result, index}
<li role="presentation">
<button
class="flex w-full cursor-pointer items-center px-4 py-2 text-left hover:bg-gray-100 dark:text-black"
id={optionId(index)}
type="button"
role="option"
tabindex="-1"
aria-selected={activeIndex === index}
aria-posinset={index + 1}
aria-setsize={results.length}
class="flex w-full cursor-pointer items-center px-4 py-2 text-left hover:bg-gray-100 dark:text-black {activeIndex ===
index
? 'bg-gray-100'
: ''}"
onclick={() => handleSelect(result)}
>
<FontAwesomeIcon icon={faMapMarkerAlt} class="mr-2 text-gray-400 " />
Expand Down
61 changes: 60 additions & 1 deletion src/components/trip-planner/__tests__/TripPlan.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/svelte';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { tick } from 'svelte';
import TripPlan from '../TripPlan.svelte';
Expand Down Expand Up @@ -98,6 +98,65 @@ describe('TripPlan pin cleanup', () => {
});
});

describe('TripPlan autocomplete dismissal', () => {
let mapProvider;
let props;

beforeEach(() => {
mapProvider = {
addPinMarker: vi.fn(),
removePinMarker: vi.fn(),
clearAllPolylines: vi.fn()
};
props = {
handleTripPlan: vi.fn(),
clearTripItineraries: vi.fn(),
mapProvider
};
});

afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
delete global.fetch;
});

it('does not reopen results when a pending response resolves after Escape', async () => {
vi.useFakeTimers();
let resolveSuggestions;
global.fetch = vi.fn(
() =>
new Promise((resolve) => {
resolveSuggestions = () =>
resolve({
ok: true,
json: () =>
Promise.resolve({
suggestions: [{ displayText: 'Capitol Hill', name: 'Capitol Hill' }]
})
});
})
);
const { container, unmount } = render(TripPlan, { props });
const input = container.querySelector('#from-location-input');

await fireEvent.input(input, { target: { value: 'Capitol' } });
await vi.advanceTimersByTimeAsync(500);
expect(global.fetch).toHaveBeenCalledOnce();

await fireEvent.keyDown(input, { key: 'Escape' });
await tick();
expect(screen.queryByRole('status')).not.toBeInTheDocument();

resolveSuggestions();
await Promise.resolve();
await tick();

expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
unmount();
});
});

describe('TripPlan shared URL round trip', () => {
let mapProvider;
let props;
Expand Down
Loading
Loading