原生 <select> 的选项弹层由 OS 定位(macOS 锚在选中项上、整体偏移=漂移),CSS 控 不了。改成自控下拉:按钮(复用药丸样式)+ 绝对定位菜单(top:100%,right:0)+ 点外/ Esc 关闭 + 选中高亮。用户中心 LangSeg 与官网 Header 同款;官网选项用 <a href> 导航 (SEO 友好)、用户中心用 setLang。两端 build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang)
|
||||
import React from 'react';
|
||||
'use client';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { Lang } from '../lib/i18n';
|
||||
|
||||
export const card: React.CSSProperties = {
|
||||
@@ -22,34 +23,110 @@ export const input: React.CSSProperties = {
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const LANGS: [Lang, string][] = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
|
||||
// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"),
|
||||
// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。
|
||||
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
// 6 语言用原生下拉(段控横排会挤)。默认 en。
|
||||
const langs: [Lang, string][] = [
|
||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||
];
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang);
|
||||
return (
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as Lang)}
|
||||
aria-label="Language"
|
||||
style={{
|
||||
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
|
||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 10,
|
||||
padding: '5px 10px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||
}}
|
||||
>
|
||||
{langs.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label="Language"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 10,
|
||||
padding: '5px 10px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--fg2)',
|
||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||
}}
|
||||
>
|
||||
<span>{current}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4"
|
||||
strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
right: 0,
|
||||
minWidth: 132,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)',
|
||||
padding: 5,
|
||||
zIndex: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{LANGS.map(([v, l]) => {
|
||||
const on = v === lang;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
onClick={() => { setLang(v); setOpen(false); }}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent',
|
||||
color: on ? 'var(--accent)' : 'var(--fg1)',
|
||||
fontWeight: on ? 700 : 500,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 13,
|
||||
padding: '8px 11px',
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user