Skip to content

Commit 346a010

Browse files
authored
feat: icon-button dropdown language switcher (kimi style) (#90)
feat: icon-button dropdown language switcher (kimi style)
2 parents 870b8f6 + 4839531 commit 346a010

5 files changed

Lines changed: 228 additions & 73 deletions

File tree

‎apps/ossheroes/src/components/Navbar.astro‎

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
import { LOCALES, localePath, localeUrl, t, type Locale } from '@opensource-win/ui';
2+
import { localePath, localeUrl, t, type Locale } from '@opensource-win/ui';
3+
import LangSwitcher from '@opensource-win/ui/LangSwitcher.astro';
34
import { HEROES_BASE as BASE, SITE_URL } from '../lib/site';
45
import { getPastYears } from '../lib/ranking';
56
@@ -15,8 +16,6 @@ const pastYears = getPastYears();
1516
/** 当前语言的榜单首页路径(锚点链接拼接用) */
1617
const home = localePath(locale, `${BASE}/`);
1718
const mainSite = localeUrl(SITE_URL, locale, '/');
18-
19-
const LANG_LABELS: Record<Locale, string> = { en: 'EN', 'zh-CN': '简', 'zh-TW': '繁' };
2019
---
2120

2221
{/* 导航 */}
@@ -56,21 +55,9 @@ const LANG_LABELS: Record<Locale, string> = { en: 'EN', 'zh-CN': '简', 'zh-TW':
5655
<li class="nav-item">
5756
<a href={mainSite} class="nav-link">{t(locale, '回到官网', 'Main Site')}</a>
5857
</li>
59-
{/* 语言切换:跳转到当前页面对应的其他语言 URL,并记录 localStorage('osw-language') */}
60-
<li class="nav-item lang-switch" aria-label="Language / 语言">
61-
{LOCALES.map((item, i) => (
62-
<>
63-
{i > 0 && <span class="lang-sep" aria-hidden="true">/</span>}
64-
<a
65-
class:list={['lang-btn', { active: item === locale }]}
66-
href={localePath(item, path)}
67-
hreflang={item}
68-
rel="alternate"
69-
aria-current={item === locale ? 'true' : undefined}
70-
onclick={`try{window.localStorage.setItem('osw-language','${item}')}catch(e){}`}
71-
>{LANG_LABELS[item]}</a>
72-
</>
73-
))}
58+
{/* 语言切换:图标按钮 + 下拉(跳对应语言 URL 并写 localStorage,见共享组件) */}
59+
<li class="nav-item">
60+
<LangSwitcher locale={locale} path={path} />
7461
</li>
7562
</ul>
7663
</div>

‎apps/ossheroes/src/styles/global.css‎

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -642,33 +642,6 @@ a.user-item:hover .nickname {
642642
line-height: 1.8;
643643
}
644644

645-
/* 导航语言切换:单按钮显示目标语言,与官网首页 #lang-toggle 同款观感 */
646-
.lang-switch {
647-
padding: 0.5rem;
648-
}
649-
.lang-btn {
650-
display: inline-flex;
651-
align-items: center;
652-
gap: 0.5rem;
653-
background: none;
654-
border: none;
655-
border-radius: 0;
656-
color: var(--osw-muted);
657-
font-family: var(--osw-font);
658-
font-size: 14px;
659-
padding: 0;
660-
cursor: pointer;
661-
white-space: nowrap;
662-
transition: color 0.2s;
663-
}
664-
.lang-btn:hover,
665-
.lang-btn:focus {
666-
color: var(--osw-primary);
667-
}
668-
.lang-btn svg {
669-
width: 1rem;
670-
height: 1rem;
671-
}
672645

673646
/* 详情页顶部终端风页面头部:grid 背景 + 径向辉光 + 扫描线 + 描边大字,零图片素材 */
674647
.detail-hero {

‎apps/www/src/components/Home.astro‎

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import Typewriter from "./Typewriter.tsx";
88
import SeoHead from "@opensource-win/ui/SeoHead.astro";
99
import FooterCredit from "@opensource-win/ui/FooterCredit.astro";
1010
import LangRedirect from "@opensource-win/ui/LangRedirect.astro";
11-
import { LOCALES, localePath, localeUrl, t, toZhTwDeep, type Locale } from "@opensource-win/ui";
11+
import LangSwitcher from "@opensource-win/ui/LangSwitcher.astro";
12+
import { localeUrl, t, toZhTwDeep, type Locale } from "@opensource-win/ui";
1213
import "../styles/global.css";
1314
1415
import heroImg from "../assets/hero_network.jpg";
@@ -40,13 +41,6 @@ const heroesUrl = localeUrl(SITE, locale, "/heroes/");
4041
const shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&hashtags=OpenSourceWin`;
4142
const xUrl = locale === "en" ? "https://x.com/sunnyygao" : "https://x.com/gaoyangio";
4243
43-
const LANG_LABELS: Record<Locale, string> = { en: "EN", "zh-CN": "简", "zh-TW": "繁" };
44-
const langLinks = LOCALES.map((item) => ({
45-
locale: item,
46-
href: localePath(item, "/"),
47-
label: LANG_LABELS[item],
48-
}));
49-
5044
/** Escape a string for safe interpolation into generated attribute HTML. */
5145
function escapeHtml(value: string): string {
5246
return value
@@ -177,25 +171,8 @@ const jsonLd = {
177171
</div>
178172

179173
<div class="flex items-center gap-4">
180-
{/* 语言切换:跳转到当前页面对应的其他语言 URL,并记录 localStorage('osw-language') */}
181-
<div class="inline-flex items-center font-mono text-sm" aria-label="Language / 语言">
182-
{langLinks.map((item, i) => (
183-
<>
184-
{i > 0 && <span class="text-muted-foreground" aria-hidden="true">/</span>}
185-
<a
186-
href={item.href}
187-
hreflang={item.locale}
188-
rel="alternate"
189-
aria-current={item.locale === locale ? "true" : undefined}
190-
onclick={`try{window.localStorage.setItem('osw-language','${item.locale}')}catch(e){}`}
191-
class:list={[
192-
"px-2 py-2 transition-colors",
193-
item.locale === locale ? "text-primary" : "text-muted-foreground hover:text-primary",
194-
]}
195-
>{item.label}</a>
196-
</>
197-
))}
198-
</div>
174+
{/* 语言切换:图标按钮 + 下拉(跳对应语言 URL 并写 localStorage,见共享组件) */}
175+
<LangSwitcher locale={locale} path="/" />
199176

200177
<a href={heroesUrl} class="inline-flex items-center justify-center border bg-background text-sm px-4 py-2 font-mono text-foreground hover:bg-accent hover:text-accent-foreground transition-colors hidden sm:inline-flex">
201178
{navHeroes}

‎packages/ui/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"./tokens.css": "./src/tokens.css",
2020
"./SeoHead.astro": "./src/SeoHead.astro",
2121
"./FooterCredit.astro": "./src/FooterCredit.astro",
22-
"./LangRedirect.astro": "./src/LangRedirect.astro"
22+
"./LangRedirect.astro": "./src/LangRedirect.astro",
23+
"./LangSwitcher.astro": "./src/LangSwitcher.astro"
2324
},
2425
"scripts": {
2526
"build": "tsc",

‎packages/ui/src/LangSwitcher.astro‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
---
2+
/**
3+
* 共享语言切换器 —— kimi 风格「翻译图标按钮 + 下拉菜单」。
4+
*
5+
* apps/www(首页导航)与 apps/ossheroes(Navbar)复用,替换旧的
6+
* 「EN / 简 / 繁」三文字链接并排,解决移动端拥挤折行问题。
7+
*
8+
* 行为契约(与全站 URL 级三语言方案一致,勿改):
9+
* - 菜单项即跨语言跳转链接:href = 当前页面对应语言 URL(localePath),
10+
* 带 hreflang + rel="alternate",onclick 写 localStorage('osw-language');
11+
* - 勾选状态构建期按 props.locale 直接渲染进 HTML,纯静态、无客户端数据依赖;
12+
* - 开关交互为下方一小段 vanilla JS:点击切换、点击外部 / Esc 关闭,aria-expanded 同步。
13+
*
14+
* 样式:scoped,颜色取自 --osw-* 设计 token(两个 app 均已引入 tokens.css),
15+
* 暗色卡片 + 圆角 + 细边框 + 阴影,菜单相对按钮右对齐。
16+
*/
17+
import { LOCALES, localePath, type Locale } from './i18n';
18+
19+
interface Props {
20+
/** 当前页面语言(构建期已知,决定 ✓ 勾选与 aria-current) */
21+
locale: Locale;
22+
/** locale 中立的规范路径(如 '/'、'/heroes/'、'/hero/<login>/'),供跨语言跳转 */
23+
path: string;
24+
}
25+
26+
const { locale, path } = Astro.props;
27+
28+
/** 下拉菜单显示的完整语言名 */
29+
const LANG_NAMES: Record<Locale, string> = {
30+
en: 'English',
31+
'zh-CN': '简体中文',
32+
'zh-TW': '繁體中文',
33+
};
34+
35+
const items = LOCALES.map((item) => ({
36+
locale: item,
37+
href: localePath(item, path),
38+
name: LANG_NAMES[item],
39+
current: item === locale,
40+
}));
41+
---
42+
43+
<div class="lang-switcher" data-lang-switcher>
44+
<button
45+
type="button"
46+
class="lang-switcher-toggle"
47+
aria-haspopup="true"
48+
aria-expanded="false"
49+
aria-label="Switch language / 切换语言"
50+
>
51+
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
52+
<path d="m5 8 6 6"></path>
53+
<path d="m4 14 6-6 2-3"></path>
54+
<path d="M2 5h12"></path>
55+
<path d="M7 2h1"></path>
56+
<path d="m22 22-5-10-5 10"></path>
57+
<path d="M14 18h6"></path>
58+
</svg>
59+
</button>
60+
<ul class="lang-switcher-menu" aria-label="Language / 语言">
61+
{items.map((item) => (
62+
<li>
63+
<a
64+
href={item.href}
65+
hreflang={item.locale}
66+
rel="alternate"
67+
aria-current={item.current ? 'true' : undefined}
68+
onclick={`try{window.localStorage.setItem('osw-language','${item.locale}')}catch(e){}`}
69+
class:list={['lang-switcher-item', { 'is-active': item.current }]}
70+
>
71+
<span>{item.name}</span>
72+
{item.current && (
73+
<svg class="lang-switcher-check" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
74+
<path d="M20 6 9 17l-5-5"></path>
75+
</svg>
76+
)}
77+
</a>
78+
</li>
79+
))}
80+
</ul>
81+
</div>
82+
83+
<style>
84+
.lang-switcher {
85+
position: relative;
86+
display: inline-flex;
87+
}
88+
89+
/* 图标按钮:与导航文字链接同节奏(0.5rem 内边距),hover/展开有 subtle 底色 */
90+
.lang-switcher-toggle {
91+
display: inline-flex;
92+
align-items: center;
93+
justify-content: center;
94+
padding: 0.5rem;
95+
background: none;
96+
border: none;
97+
border-radius: 0.5rem;
98+
color: var(--osw-muted);
99+
cursor: pointer;
100+
transition:
101+
color 0.2s,
102+
background-color 0.2s;
103+
}
104+
.lang-switcher-toggle:hover,
105+
.lang-switcher.open .lang-switcher-toggle {
106+
color: var(--osw-primary);
107+
background-color: oklch(0.85 0.2 140 / 0.08);
108+
}
109+
.lang-switcher-toggle:focus-visible {
110+
outline: 2px solid var(--osw-primary);
111+
outline-offset: 2px;
112+
}
113+
.lang-switcher-toggle svg {
114+
width: 1.125rem;
115+
height: 1.125rem;
116+
}
117+
118+
/* 下拉卡片:暗色 + 圆角 + 细边框 + 阴影,相对按钮右对齐 */
119+
.lang-switcher-menu {
120+
display: none;
121+
position: absolute;
122+
right: 0;
123+
top: calc(100% + 0.375rem);
124+
z-index: 60;
125+
min-width: 9.5rem;
126+
max-width: calc(100vw - 2rem);
127+
margin: 0;
128+
padding: 0.375rem;
129+
list-style: none;
130+
background-color: var(--osw-card);
131+
border: 1px solid var(--osw-border);
132+
border-radius: 0.75rem;
133+
box-shadow:
134+
0 10px 30px oklch(0 0 0 / 0.5),
135+
0 2px 8px oklch(0 0 0 / 0.4);
136+
}
137+
.lang-switcher.open .lang-switcher-menu {
138+
display: block;
139+
}
140+
141+
.lang-switcher-item {
142+
display: flex;
143+
align-items: center;
144+
justify-content: space-between;
145+
gap: 1rem;
146+
padding: 0.5rem 0.75rem;
147+
border-radius: 0.5rem;
148+
color: var(--osw-muted);
149+
font-size: 0.875rem;
150+
line-height: 1.4;
151+
white-space: nowrap;
152+
text-decoration: none;
153+
transition:
154+
color 0.15s,
155+
background-color 0.15s;
156+
}
157+
.lang-switcher-item:hover,
158+
.lang-switcher-item:focus-visible {
159+
color: var(--osw-primary);
160+
background-color: oklch(0.85 0.2 140 / 0.08);
161+
outline: none;
162+
}
163+
.lang-switcher-item.is-active {
164+
color: var(--osw-primary);
165+
}
166+
.lang-switcher-check {
167+
width: 1rem;
168+
height: 1rem;
169+
flex-shrink: 0;
170+
}
171+
</style>
172+
173+
<script>
174+
/* 语言切换器开关:点击按钮切换,点击外部 / Esc 关闭并同步 aria-expanded。
175+
* Astro 会把这段脚本按页面打包一次,多实例共存时用 closest 委托即可。 */
176+
function initLangSwitchers() {
177+
const switchers = document.querySelectorAll<HTMLElement>('[data-lang-switcher]');
178+
179+
function closeAll(except?: HTMLElement) {
180+
switchers.forEach((switcher) => {
181+
if (switcher === except) return;
182+
switcher.classList.remove('open');
183+
switcher.querySelector('.lang-switcher-toggle')?.setAttribute('aria-expanded', 'false');
184+
});
185+
}
186+
187+
switchers.forEach((switcher) => {
188+
const toggle = switcher.querySelector<HTMLButtonElement>('.lang-switcher-toggle');
189+
if (!toggle) return;
190+
toggle.addEventListener('click', () => {
191+
const willOpen = !switcher.classList.contains('open');
192+
closeAll(switcher);
193+
switcher.classList.toggle('open', willOpen);
194+
toggle.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
195+
});
196+
});
197+
198+
document.addEventListener('click', (event) => {
199+
if (!(event.target as HTMLElement).closest('[data-lang-switcher]')) closeAll();
200+
});
201+
document.addEventListener('keydown', (event) => {
202+
if (event.key === 'Escape') {
203+
closeAll();
204+
// Esc 关闭后把焦点还给仍聚焦在菜单内的触发按钮
205+
const active = document.activeElement as HTMLElement | null;
206+
if (active && !active.closest('[data-lang-switcher]')) return;
207+
active?.closest('[data-lang-switcher]')?.querySelector<HTMLElement>('.lang-switcher-toggle')?.focus();
208+
}
209+
});
210+
}
211+
212+
if (document.readyState === 'loading') {
213+
document.addEventListener('DOMContentLoaded', initLangSwitchers);
214+
} else {
215+
initLangSwitchers();
216+
}
217+
</script>

0 commit comments

Comments
 (0)