Skip to content

Commit aa49270

Browse files
feat(components): replace language select with React DocsLanguageSwitcher
Replace the Starlight-based language dropdown with a custom React DocsLanguageSwitcher component featuring a dialog-based locale picker. Wire the new component into StarlightHeader and update persistence tests to match the new interaction model. Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent c78f6ac commit aa49270

6 files changed

Lines changed: 715 additions & 305 deletions
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
.languageSwitcher {
2+
position: relative;
3+
display: inline-flex;
4+
align-items: center;
5+
}
6+
7+
.languageTrigger {
8+
display: inline-flex;
9+
align-items: center;
10+
gap: 0.55rem;
11+
min-height: 2.25rem;
12+
padding: 0 0.875rem;
13+
border: 1px solid var(--sl-color-gray-5);
14+
border-radius: 999px;
15+
background: color-mix(in srgb, var(--sl-color-black) 8%, transparent);
16+
color: var(--sl-color-text-high);
17+
cursor: pointer;
18+
font: inherit;
19+
transition:
20+
background-color var(--sl-transition-fast),
21+
border-color var(--sl-transition-fast),
22+
transform var(--sl-transition-fast);
23+
}
24+
25+
.languageTrigger:hover {
26+
background: var(--sl-color-gray-6);
27+
border-color: var(--sl-color-accent-high);
28+
}
29+
30+
.languageTrigger:focus-visible {
31+
outline: 2px solid var(--sl-color-accent-high);
32+
outline-offset: 2px;
33+
}
34+
35+
.triggerLabel {
36+
max-width: 10rem;
37+
overflow: hidden;
38+
text-overflow: ellipsis;
39+
white-space: nowrap;
40+
font-size: var(--sl-text-sm);
41+
font-weight: var(--sl-font-weight-medium);
42+
}
43+
44+
.languageTriggerChevron {
45+
font-size: 0.75rem;
46+
transition: transform var(--sl-transition-fast);
47+
}
48+
49+
.languageSwitcher[data-open='true'] .languageTriggerChevron {
50+
transform: rotate(180deg);
51+
}
52+
53+
.languageBackdrop {
54+
position: fixed;
55+
inset: 0;
56+
z-index: 1100;
57+
border: 0;
58+
background: rgba(15, 23, 42, 0.52);
59+
padding: 0;
60+
}
61+
62+
.languageDialog {
63+
position: fixed;
64+
top: 8vh;
65+
right: max(2rem, 10vw);
66+
bottom: 8vh;
67+
left: max(2rem, 10vw);
68+
z-index: 1101;
69+
display: flex;
70+
flex-direction: column;
71+
gap: 1rem;
72+
padding: 1.25rem;
73+
border: 1px solid var(--sl-color-gray-5);
74+
border-radius: 1rem;
75+
background: color-mix(in srgb, var(--sl-color-bg) 94%, transparent);
76+
box-shadow: 0 24px 64px rgba(2, 6, 23, 0.32);
77+
}
78+
79+
.languageDialogHeader {
80+
display: flex;
81+
align-items: flex-start;
82+
justify-content: space-between;
83+
gap: 1rem;
84+
}
85+
86+
.languageDialogTitle {
87+
margin: 0;
88+
font-size: 1rem;
89+
font-weight: 700;
90+
color: var(--sl-color-white);
91+
}
92+
93+
.languageDialogCurrent {
94+
margin: 0.35rem 0 0;
95+
color: var(--sl-color-text);
96+
font-size: var(--sl-text-sm);
97+
}
98+
99+
.languageDialogClose {
100+
display: inline-flex;
101+
align-items: center;
102+
justify-content: center;
103+
width: 2.25rem;
104+
height: 2.25rem;
105+
border: 1px solid var(--sl-color-gray-5);
106+
border-radius: 999px;
107+
background: transparent;
108+
color: var(--sl-color-text-high);
109+
cursor: pointer;
110+
font: inherit;
111+
font-size: 1.125rem;
112+
}
113+
114+
.languageDialogClose:hover {
115+
background: var(--sl-color-gray-6);
116+
border-color: var(--sl-color-accent-high);
117+
}
118+
119+
.languageDialogClose:focus-visible {
120+
outline: 2px solid var(--sl-color-accent-high);
121+
outline-offset: 2px;
122+
}
123+
124+
.languageGridScroller {
125+
min-height: 0;
126+
overflow: auto;
127+
border: 1px solid var(--sl-color-gray-5);
128+
border-radius: 0.875rem;
129+
background: color-mix(in srgb, var(--sl-color-black) 6%, transparent);
130+
padding: 1rem;
131+
}
132+
133+
.languageGrid {
134+
display: grid;
135+
grid-template-columns: repeat(4, minmax(0, 1fr));
136+
gap: 0.75rem;
137+
}
138+
139+
.languageOptionButton {
140+
display: flex;
141+
min-height: 4.75rem;
142+
width: 100%;
143+
flex-direction: column;
144+
align-items: center;
145+
justify-content: center;
146+
gap: 0.45rem;
147+
border: 1px solid var(--sl-color-gray-5);
148+
border-radius: 0.875rem;
149+
background: color-mix(in srgb, var(--sl-color-bg-nav) 88%, transparent);
150+
color: var(--sl-color-text-high);
151+
cursor: pointer;
152+
font: inherit;
153+
font-size: 0.95rem;
154+
font-weight: 700;
155+
padding: 0.85rem;
156+
text-align: center;
157+
transition:
158+
background-color var(--sl-transition-fast),
159+
border-color var(--sl-transition-fast),
160+
transform var(--sl-transition-fast);
161+
}
162+
163+
.languageOptionButton:hover {
164+
background: var(--sl-color-gray-6);
165+
border-color: var(--sl-color-accent-high);
166+
transform: translateY(-1px);
167+
}
168+
169+
.languageOptionButton:focus-visible {
170+
outline: 2px solid var(--sl-color-accent-high);
171+
outline-offset: 2px;
172+
}
173+
174+
.languageOptionSelected {
175+
border-color: var(--sl-color-accent-high);
176+
background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
177+
}
178+
179+
.languageOptionLabel {
180+
display: block;
181+
}
182+
183+
.languageOptionSelectedBadge {
184+
display: inline-flex;
185+
align-items: center;
186+
justify-content: center;
187+
min-height: 1.45rem;
188+
padding: 0 0.55rem;
189+
border-radius: 999px;
190+
background: color-mix(in srgb, var(--sl-color-accent-high) 18%, transparent);
191+
color: var(--sl-color-accent-high);
192+
font-size: 0.75rem;
193+
font-weight: 700;
194+
}
195+
196+
@media (max-width: 72rem) {
197+
.languageDialog {
198+
right: 6vw;
199+
left: 6vw;
200+
}
201+
202+
.languageGrid {
203+
grid-template-columns: repeat(3, minmax(0, 1fr));
204+
}
205+
}
206+
207+
@media (max-width: 50rem) {
208+
.languageDialog {
209+
top: 6vh;
210+
right: 4vw;
211+
bottom: 6vh;
212+
left: 4vw;
213+
padding: 1rem;
214+
}
215+
216+
.languageDialogHeader {
217+
align-items: center;
218+
}
219+
220+
.languageGrid {
221+
grid-template-columns: repeat(2, minmax(0, 1fr));
222+
}
223+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// @vitest-environment jsdom
2+
3+
import '@testing-library/jest-dom/vitest';
4+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import DocsLanguageSwitcher, {
8+
buildDocsLocaleNavigationTarget,
9+
persistDocsLocaleSelection,
10+
} from './DocsLanguageSwitcher';
11+
12+
const options = [
13+
{ code: 'root', label: '简体中文', lang: 'zh-CN' },
14+
{ code: 'en-US', label: 'English', lang: 'en-US' },
15+
{ code: 'fr-FR', label: 'Français', lang: 'fr-FR' },
16+
] as const;
17+
18+
describe('DocsLanguageSwitcher', () => {
19+
beforeEach(() => {
20+
localStorage.clear();
21+
window.history.replaceState({}, '', '/product-overview/?tab=pricing#install');
22+
});
23+
24+
afterEach(() => {
25+
cleanup();
26+
vi.restoreAllMocks();
27+
});
28+
29+
function renderSwitcher(
30+
currentLocale: 'root' | 'en-US' | 'fr-FR' = 'en-US',
31+
onNavigate?: (targetUrl: string) => void,
32+
) {
33+
return render(
34+
<DocsLanguageSwitcher
35+
currentLocale={currentLocale}
36+
currentPathname="/product-overview/"
37+
options={options}
38+
triggerAriaLabel="Select language"
39+
dialogTitle="Select language"
40+
currentLocaleLabel="Current language"
41+
closeLabel="Close language dialog"
42+
selectedStateLabel="Selected"
43+
onNavigate={onNavigate}
44+
/>,
45+
);
46+
}
47+
48+
it('opens the chooser, renders all options, and closes on Escape', () => {
49+
renderSwitcher('en-US');
50+
51+
const trigger = screen.getByRole('button', { name: /Select language/i });
52+
fireEvent.click(trigger);
53+
54+
expect(screen.getByRole('dialog')).toBeInTheDocument();
55+
expect(screen.getAllByRole('option')).toHaveLength(options.length);
56+
expect(screen.getByRole('option', { name: /English/i })).toHaveAttribute('aria-selected', 'true');
57+
58+
fireEvent.keyDown(document, { key: 'Escape' });
59+
60+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
61+
expect(document.activeElement).toBe(trigger);
62+
});
63+
64+
it('supports keyboard navigation across locale options', () => {
65+
renderSwitcher('en-US');
66+
67+
fireEvent.click(screen.getByRole('button', { name: /Select language/i }));
68+
69+
const englishOption = screen.getByRole('option', { name: /English/i });
70+
englishOption.focus();
71+
fireEvent.keyDown(englishOption, { key: 'ArrowRight' });
72+
expect(document.activeElement).toBe(screen.getByRole('option', { name: /Français/i }));
73+
74+
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' });
75+
expect(document.activeElement).toBe(screen.getByRole('option', { name: //i }));
76+
77+
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' });
78+
expect(document.activeElement).toBe(screen.getByRole('option', { name: /Français/i }));
79+
});
80+
81+
it('closes without navigation when selecting the active locale', () => {
82+
const onNavigate = vi.fn();
83+
renderSwitcher('en-US', onNavigate);
84+
85+
fireEvent.click(screen.getByRole('button', { name: /Select language/i }));
86+
fireEvent.click(screen.getByRole('option', { name: /English/i }));
87+
88+
expect(onNavigate).not.toHaveBeenCalled();
89+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
90+
});
91+
92+
it('persists the selected locale before navigating to the counterpart route', () => {
93+
localStorage.setItem('starlight-route', JSON.stringify({ path: '/product-overview/', lang: 'root' }));
94+
const onNavigate = vi.fn();
95+
renderSwitcher('en-US', onNavigate);
96+
97+
fireEvent.click(screen.getByRole('button', { name: /Select language/i }));
98+
fireEvent.click(screen.getByRole('option', { name: /Français/i }));
99+
100+
expect(onNavigate).toHaveBeenCalledWith(
101+
new URL('/fr-FR/product-overview/?tab=pricing#install', window.location.origin).toString(),
102+
);
103+
expect(JSON.parse(localStorage.getItem('starlight-route') ?? '{}')).toEqual({
104+
path: '/product-overview/',
105+
lang: 'fr-FR',
106+
});
107+
});
108+
});
109+
110+
describe('DocsLanguageSwitcher helpers', () => {
111+
afterEach(() => {
112+
localStorage.clear();
113+
});
114+
115+
it('builds counterpart navigation targets that preserve query strings and hashes', () => {
116+
const targetUrl = buildDocsLocaleNavigationTarget(
117+
'fr-FR',
118+
new URL('https://docs.hagicode.com/en-US/product-overview/?tab=pricing#install'),
119+
'/en-US/product-overview/',
120+
);
121+
122+
expect(targetUrl.toString()).toBe(
123+
'https://docs.hagicode.com/fr-FR/product-overview/?tab=pricing#install',
124+
);
125+
});
126+
127+
it('preserves unrelated starlight-route fields when persisting the locale', () => {
128+
localStorage.setItem(
129+
'starlight-route',
130+
JSON.stringify({ path: '/en-US/product-overview/', lang: 'en-US', version: 'latest' }),
131+
);
132+
133+
persistDocsLocaleSelection('fr-FR');
134+
135+
expect(JSON.parse(localStorage.getItem('starlight-route') ?? '{}')).toEqual({
136+
path: '/en-US/product-overview/',
137+
lang: 'fr-FR',
138+
version: 'latest',
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)