fix(NodeSwitcher): use ui-select-menu/item classes for consistent styling
Build and Deploy / Build and Push Docker Image (push) Successful in 1m57s
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:
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { MonitorDot } from 'lucide-react'
|
import { MonitorDot, ChevronDown } from 'lucide-react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
activeInterconnectNodeId,
|
activeInterconnectNodeId,
|
||||||
api,
|
api,
|
||||||
@@ -15,6 +16,8 @@ export function NodeSwitcher() {
|
|||||||
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local')
|
const [selected, setSelected] = useState(activeInterconnectNodeId || 'local')
|
||||||
const [isMaster, setIsMaster] = useState(false)
|
const [isMaster, setIsMaster] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const fetchNodes = useCallback(async () => {
|
const fetchNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,9 +96,52 @@ export function NodeSwitcher() {
|
|||||||
|
|
||||||
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -107,31 +153,25 @@ export function NodeSwitcher() {
|
|||||||
>
|
>
|
||||||
<MonitorDot size={12} className="shrink-0" />
|
<MonitorDot size={12} className="shrink-0" />
|
||||||
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
||||||
|
<ChevronDown size={12} className="shrink-0 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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">
|
createPortal(
|
||||||
{allOptions.map((opt) => (
|
<div className="ui-select-menu" style={menuStyle}>
|
||||||
<button
|
{allOptions.map((opt) => (
|
||||||
key={opt.value}
|
<button
|
||||||
type="button"
|
key={opt.value}
|
||||||
onClick={() => handleSelect(opt.value)}
|
type="button"
|
||||||
className={cn(
|
onClick={() => handleSelect(opt.value)}
|
||||||
'w-full px-3 py-1.5 text-left text-xs transition-colors',
|
className={cn('ui-select-item', opt.value === selected && 'is-active')}
|
||||||
opt.value === selected
|
>
|
||||||
? 'bg-[var(--bg-selected)] text-[var(--text-primary)]'
|
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]',
|
</button>
|
||||||
)}
|
))}
|
||||||
>
|
</div>,
|
||||||
{opt.label}
|
document.body,
|
||||||
</button>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="fixed inset-0 z-[70]" onClick={() => setOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user