Skip to content

Commit 30a35b1

Browse files
feat: add language selection for turn-by-turn navigation instructions (#286)
1 parent ca61bfd commit 30a35b1

File tree

8 files changed

+349
-3
lines changed

8 files changed

+349
-3
lines changed

src/components/settings-panel/settings-options.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,45 @@ export const generalize = {
753753
},
754754
};
755755

756+
// Valhalla supported language options for turn-by-turn navigation instructions
757+
// Reference: https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/#supported-language-tags
758+
export const languageOptions = [
759+
{ key: 'bg-BG', text: 'Bulgarian (Bulgaria)', value: 'bg-BG' },
760+
{ key: 'ca-ES', text: 'Catalan (Spain)', value: 'ca-ES' },
761+
{ key: 'cs-CZ', text: 'Czech (Czech Republic)', value: 'cs-CZ' },
762+
{ key: 'da-DK', text: 'Danish (Denmark)', value: 'da-DK' },
763+
{ key: 'de-DE', text: 'German (Germany)', value: 'de-DE' },
764+
{ key: 'el-GR', text: 'Greek (Greece)', value: 'el-GR' },
765+
{ key: 'en-GB', text: 'English (United Kingdom)', value: 'en-GB' },
766+
{ key: 'en-US', text: 'English (United States)', value: 'en-US' },
767+
{ key: 'en-US-x-pirate', text: 'English (Pirate)', value: 'en-US-x-pirate' },
768+
{ key: 'es-ES', text: 'Spanish (Spain)', value: 'es-ES' },
769+
{ key: 'et-EE', text: 'Estonian (Estonia)', value: 'et-EE' },
770+
{ key: 'fi-FI', text: 'Finnish (Finland)', value: 'fi-FI' },
771+
{ key: 'fr-FR', text: 'French (France)', value: 'fr-FR' },
772+
{ key: 'hi-IN', text: 'Hindi (India)', value: 'hi-IN' },
773+
{ key: 'hu-HU', text: 'Hungarian (Hungary)', value: 'hu-HU' },
774+
{ key: 'it-IT', text: 'Italian (Italy)', value: 'it-IT' },
775+
{ key: 'ja-JP', text: 'Japanese (Japan)', value: 'ja-JP' },
776+
{ key: 'nb-NO', text: 'Bokmal (Norway)', value: 'nb-NO' },
777+
{ key: 'nl-NL', text: 'Dutch (Netherlands)', value: 'nl-NL' },
778+
{ key: 'pl-PL', text: 'Polish (Poland)', value: 'pl-PL' },
779+
{ key: 'pt-BR', text: 'Portuguese (Brazil)', value: 'pt-BR' },
780+
{ key: 'pt-PT', text: 'Portuguese (Portugal)', value: 'pt-PT' },
781+
{ key: 'ro-RO', text: 'Romanian (Romania)', value: 'ro-RO' },
782+
{ key: 'ru-RU', text: 'Russian (Russia)', value: 'ru-RU' },
783+
{ key: 'sk-SK', text: 'Slovak (Slovakia)', value: 'sk-SK' },
784+
{ key: 'sl-SI', text: 'Slovenian (Slovenia)', value: 'sl-SI' },
785+
{ key: 'sv-SE', text: 'Swedish (Sweden)', value: 'sv-SE' },
786+
{ key: 'tr-TR', text: 'Turkish (Türkiye)', value: 'tr-TR' },
787+
{ key: 'uk-UA', text: 'Ukrainian (Ukraine)', value: 'uk-UA' },
788+
] as const;
789+
790+
export type DirectionsLanguage = (typeof languageOptions)[number]['value'];
791+
792+
export const DEFAULT_DIRECTIONS_LANGUAGE: DirectionsLanguage = 'en-US';
793+
export const DIRECTIONS_LANGUAGE_STORAGE_KEY = 'directions_language';
794+
756795
export const settingsInit = {
757796
maneuver_penalty: 5,
758797
country_crossing_penalty: 0,

src/components/settings-panel/settings-panel.spec.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { SettingsPanel } from './settings-panel';
5+
import { DIRECTIONS_LANGUAGE_STORAGE_KEY } from './settings-options';
56

67
const mockUpdateSettings = vi.fn();
78
const mockResetSettings = vi.fn();
89
const mockToggleSettings = vi.fn();
910
const mockRefetchDirections = vi.fn();
1011
const mockRefetchIsochrones = vi.fn();
1112

13+
const mockUseParams = vi.fn(() => ({ activeTab: 'directions' }));
14+
const mockUseSearch = vi.fn(() => ({ profile: 'bicycle' }));
15+
1216
vi.mock('@tanstack/react-router', () => ({
13-
useParams: vi.fn(() => ({ activeTab: 'directions' })),
14-
useSearch: vi.fn(() => ({ profile: 'bicycle' })),
17+
useParams: () => mockUseParams(),
18+
useSearch: () => mockUseSearch(),
1519
}));
1620

1721
vi.mock('@/stores/common-store', () => ({
@@ -60,12 +64,20 @@ vi.mock('@/hooks/use-isochrones-queries', () => ({
6064
}));
6165

6266
describe('SettingsPanel', () => {
67+
const originalNavigator = global.navigator;
68+
6369
beforeEach(() => {
6470
vi.clearAllMocks();
71+
localStorage.clear();
72+
mockUseParams.mockReturnValue({ activeTab: 'directions' });
73+
mockUseSearch.mockReturnValue({ profile: 'bicycle' });
74+
vi.stubGlobal('navigator', { ...originalNavigator, language: 'en-US' });
6575
});
6676

6777
afterEach(() => {
6878
vi.restoreAllMocks();
79+
localStorage.clear();
80+
vi.stubGlobal('navigator', originalNavigator);
6981
});
7082

7183
it('should render without crashing', () => {
@@ -203,4 +215,49 @@ describe('SettingsPanel', () => {
203215
expect(screen.getByText('Use Living Streets')).toBeInTheDocument();
204216
expect(screen.getByText('Turn Penalty')).toBeInTheDocument();
205217
});
218+
219+
describe('Language Picker', () => {
220+
it('should render Directions Language section when activeTab is directions', () => {
221+
render(<SettingsPanel />);
222+
expect(screen.getByText('Directions Language')).toBeInTheDocument();
223+
expect(screen.getByText('Language')).toBeInTheDocument();
224+
});
225+
226+
it('should not render Directions Language section when activeTab is isochrones', () => {
227+
mockUseParams.mockReturnValue({ activeTab: 'isochrones' });
228+
render(<SettingsPanel />);
229+
expect(screen.queryByText('Directions Language')).not.toBeInTheDocument();
230+
});
231+
232+
it('should use system locale when no language is stored', () => {
233+
vi.stubGlobal('navigator', { language: 'fr-FR' });
234+
render(<SettingsPanel />);
235+
expect(screen.getByText('French (France)')).toBeInTheDocument();
236+
});
237+
238+
it('should fall back to en-US when system locale is not supported', () => {
239+
vi.stubGlobal('navigator', { language: 'xx-XX' });
240+
render(<SettingsPanel />);
241+
expect(screen.getByText('English (United States)')).toBeInTheDocument();
242+
});
243+
244+
it('should use stored language from localStorage on initial render', () => {
245+
localStorage.setItem(DIRECTIONS_LANGUAGE_STORAGE_KEY, 'de-DE');
246+
render(<SettingsPanel />);
247+
expect(screen.getByText('German (Germany)')).toBeInTheDocument();
248+
});
249+
250+
it('should render language select with correct id', () => {
251+
render(<SettingsPanel />);
252+
const languageSelect = screen.getByRole('combobox', {
253+
name: /Language/i,
254+
});
255+
expect(languageSelect).toBeInTheDocument();
256+
});
257+
258+
it('should render language description in help tooltip', () => {
259+
render(<SettingsPanel />);
260+
expect(screen.getByText('Language')).toBeInTheDocument();
261+
});
262+
});
206263
});

src/components/settings-panel/settings-panel.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { useState, useCallback } from 'react';
22
import { Button } from '@/components/ui/button';
3-
import { profileSettings, generalSettings } from './settings-options';
3+
import {
4+
profileSettings,
5+
generalSettings,
6+
languageOptions,
7+
type DirectionsLanguage,
8+
} from './settings-options';
49
import { filterProfileSettings } from '@/utils/filter-profile-settings';
10+
import {
11+
getDirectionsLanguage,
12+
setDirectionsLanguage,
13+
} from '@/utils/directions-language';
514
import type { PossibleSettings } from '@/components/types';
615

716
import { SliderSetting } from '@/components/ui/slider-setting';
@@ -35,6 +44,20 @@ export const SettingsPanel = () => {
3544
const { refetch: refetchDirections } = useDirectionsQuery();
3645
const { refetch: refetchIsochrones } = useIsochronesQuery();
3746

47+
const [language, setLanguage] = useState<DirectionsLanguage>(() =>
48+
getDirectionsLanguage()
49+
);
50+
51+
const handleLanguageChange = useCallback(
52+
(value: string) => {
53+
const newLanguage = value as DirectionsLanguage;
54+
setDirectionsLanguage(newLanguage);
55+
setLanguage(newLanguage);
56+
refetchDirections();
57+
},
58+
[refetchDirections]
59+
);
60+
3861
const handleMakeRequest = useCallback(() => {
3962
if (activeTab === 'directions') {
4063
refetchDirections();
@@ -104,6 +127,26 @@ export const SettingsPanel = () => {
104127
</SheetHeader>
105128
<div className="px-3">
106129
<div className="flex flex-col gap-3 border rounded-md p-2 px-3 mb-3">
130+
{activeTab === 'directions' && (
131+
<>
132+
<section>
133+
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase mb-1">
134+
Directions Language
135+
</h3>
136+
<SelectSetting
137+
id="directions-language"
138+
label="Language"
139+
description="The language used for turn-by-turn navigation instructions"
140+
placeholder="Select Language"
141+
value={language}
142+
options={[...languageOptions]}
143+
onValueChange={handleLanguageChange}
144+
/>
145+
</section>
146+
<Separator />
147+
</>
148+
)}
149+
107150
{hasProfileSettings && (
108151
<section>
109152
<div className="flex items-baseline justify-between">

src/hooks/use-directions-queries.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
parseGeocodeResponse,
1919
} from '@/utils/nominatim';
2020
import { filterProfileSettings } from '@/utils/filter-profile-settings';
21+
import { getDirectionsLanguage } from '@/utils/directions-language';
2122
import { useCommonStore } from '@/stores/common-store';
2223
import { useDirectionsStore, type Waypoint } from '@/stores/directions-store';
2324
import { router } from '@/routes';
@@ -40,12 +41,15 @@ async function fetchDirections() {
4041
}
4142

4243
const settings = filterProfileSettings(profile || 'bicycle', rawSettings);
44+
const language = getDirectionsLanguage();
45+
4346
const valhallaRequest = buildDirectionsRequest({
4447
profile: profile || 'bicycle',
4548
activeWaypoints,
4649
// @ts-expect-error todo: initial settings and filtered settings types mismatch
4750
settings,
4851
dateTime,
52+
language,
4953
});
5054

5155
const { data } = await axios.get<ValhallaRouteResponse>(
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import {
3+
getDirectionsLanguage,
4+
setDirectionsLanguage,
5+
} from './directions-language';
6+
import {
7+
DEFAULT_DIRECTIONS_LANGUAGE,
8+
DIRECTIONS_LANGUAGE_STORAGE_KEY,
9+
} from '@/components/settings-panel/settings-options';
10+
11+
describe('directions-language', () => {
12+
const originalNavigator = global.navigator;
13+
14+
beforeEach(() => {
15+
localStorage.clear();
16+
vi.clearAllMocks();
17+
vi.stubGlobal('navigator', { language: 'en-US' });
18+
});
19+
20+
afterEach(() => {
21+
localStorage.clear();
22+
vi.stubGlobal('navigator', originalNavigator);
23+
});
24+
25+
describe('getDirectionsLanguage', () => {
26+
it('should return system language when localStorage is empty and locale is supported', () => {
27+
vi.stubGlobal('navigator', { language: 'de-DE' });
28+
expect(getDirectionsLanguage()).toBe('de-DE');
29+
});
30+
31+
it('should return default language when localStorage is empty and locale is not supported', () => {
32+
vi.stubGlobal('navigator', { language: 'xx-XX' });
33+
expect(getDirectionsLanguage()).toBe(DEFAULT_DIRECTIONS_LANGUAGE);
34+
});
35+
36+
it('should match partial locale when exact match not found', () => {
37+
vi.stubGlobal('navigator', { language: 'fr-CA' });
38+
expect(getDirectionsLanguage()).toBe('fr-FR');
39+
});
40+
41+
it('should return stored language when valid', () => {
42+
localStorage.setItem(DIRECTIONS_LANGUAGE_STORAGE_KEY, 'de-DE');
43+
expect(getDirectionsLanguage()).toBe('de-DE');
44+
});
45+
46+
it('should return system language when stored value is invalid', () => {
47+
vi.stubGlobal('navigator', { language: 'es-ES' });
48+
localStorage.setItem(DIRECTIONS_LANGUAGE_STORAGE_KEY, 'invalid-lang');
49+
expect(getDirectionsLanguage()).toBe('es-ES');
50+
});
51+
52+
it('should return stored language for all supported languages', () => {
53+
const supportedLanguages = [
54+
'bg-BG',
55+
'ca-ES',
56+
'cs-CZ',
57+
'da-DK',
58+
'de-DE',
59+
'el-GR',
60+
'en-GB',
61+
'en-US',
62+
'en-US-x-pirate',
63+
'es-ES',
64+
'et-EE',
65+
'fi-FI',
66+
'fr-FR',
67+
'hi-IN',
68+
'hu-HU',
69+
'it-IT',
70+
'ja-JP',
71+
'nb-NO',
72+
'nl-NL',
73+
'pl-PL',
74+
'pt-BR',
75+
'pt-PT',
76+
'ro-RO',
77+
'ru-RU',
78+
'sk-SK',
79+
'sl-SI',
80+
'sv-SE',
81+
'tr-TR',
82+
'uk-UA',
83+
];
84+
85+
for (const lang of supportedLanguages) {
86+
localStorage.setItem(DIRECTIONS_LANGUAGE_STORAGE_KEY, lang);
87+
expect(getDirectionsLanguage()).toBe(lang);
88+
}
89+
});
90+
});
91+
92+
describe('setDirectionsLanguage', () => {
93+
it('should save language to localStorage', () => {
94+
setDirectionsLanguage('fr-FR');
95+
expect(localStorage.getItem(DIRECTIONS_LANGUAGE_STORAGE_KEY)).toBe(
96+
'fr-FR'
97+
);
98+
});
99+
100+
it('should overwrite existing language in localStorage', () => {
101+
setDirectionsLanguage('de-DE');
102+
expect(localStorage.getItem(DIRECTIONS_LANGUAGE_STORAGE_KEY)).toBe(
103+
'de-DE'
104+
);
105+
106+
setDirectionsLanguage('es-ES');
107+
expect(localStorage.getItem(DIRECTIONS_LANGUAGE_STORAGE_KEY)).toBe(
108+
'es-ES'
109+
);
110+
});
111+
112+
it('should allow setting the default language', () => {
113+
setDirectionsLanguage('tr-TR');
114+
setDirectionsLanguage(DEFAULT_DIRECTIONS_LANGUAGE);
115+
expect(localStorage.getItem(DIRECTIONS_LANGUAGE_STORAGE_KEY)).toBe(
116+
DEFAULT_DIRECTIONS_LANGUAGE
117+
);
118+
});
119+
});
120+
121+
describe('integration', () => {
122+
it('should get what was set', () => {
123+
setDirectionsLanguage('ja-JP');
124+
expect(getDirectionsLanguage()).toBe('ja-JP');
125+
});
126+
127+
it('should persist across calls', () => {
128+
setDirectionsLanguage('nl-NL');
129+
expect(getDirectionsLanguage()).toBe('nl-NL');
130+
expect(getDirectionsLanguage()).toBe('nl-NL');
131+
});
132+
});
133+
});

0 commit comments

Comments
 (0)