fix(NodeSwitcher): use ui-select-menu/item classes for consistent styling
Build and Deploy / Build and Push Docker Image (push) Successful in 1m57s

- Use createPortal to render menu in body
- Add ChevronDown icon
- Add position calculation for menu placement
- Fix ref usage with useRef instead of useState
This commit is contained in:
2026-08-08 02:50:10 +08:00
parent ce9d1b4f1c
commit e2063dbeb8
+56 -16
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { MonitorDot } from 'lucide-react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { MonitorDot, ChevronDown } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
activeInterconnectNodeId,
api,
@@ -15,6 +16,8 @@ export function NodeSwitcher() {
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local')
const [isMaster, setIsMaster] = useState(false)
const [open, setOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
const buttonRef = useRef<HTMLButtonElement>(null)
const fetchNodes = useCallback(async () => {
try {
@@ -93,9 +96,52 @@ export function NodeSwitcher() {
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
const updateMenuPosition = () => {
const el = buttonRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const viewportH = window.innerHeight
const spaceBelow = viewportH - rect.bottom
const openUp = spaceBelow < 220 && rect.top > spaceBelow
setMenuStyle({
position: 'fixed',
left: rect.left,
width: Math.max(rect.width, 140),
zIndex: 80,
...(openUp
? { bottom: viewportH - rect.top + 6, top: 'auto' }
: { top: rect.bottom + 6, bottom: 'auto' }),
})
}
useEffect(() => {
if (!open) return
updateMenuPosition()
const onDoc = (e: MouseEvent) => {
const t = e.target as Node
if (buttonRef.current?.contains(t)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
const onReposition = () => updateMenuPosition()
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
window.addEventListener('resize', onReposition)
window.addEventListener('scroll', onReposition, true)
return () => {
document.removeEventListener('mousedown', onDoc)
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', onReposition)
window.removeEventListener('scroll', onReposition, true)
}
}, [open])
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(!open)}
className={cn(
@@ -107,30 +153,24 @@ export function NodeSwitcher() {
>
<MonitorDot size={12} className="shrink-0" />
<span className="max-w-[100px] truncate">{currentLabel}</span>
<ChevronDown size={12} className="shrink-0 opacity-60" />
</button>
{open && (
<div className="absolute left-0 top-full z-[80] mt-1 min-w-[140px] rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] py-1 shadow-lg">
{open &&
createPortal(
<div className="ui-select-menu" style={menuStyle}>
{allOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSelect(opt.value)}
className={cn(
'w-full px-3 py-1.5 text-left text-xs transition-colors',
opt.value === selected
? 'bg-[var(--bg-selected)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
)}
className={cn('ui-select-item', opt.value === selected && 'is-active')}
>
{opt.label}
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
</button>
))}
</div>
)}
{open && (
<div className="fixed inset-0 z-[70]" onClick={() => setOpen(false)} />
</div>,
document.body,
)}
</div>
)