450 lines
20 KiB
TypeScript
450 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { AppIcon } from '@/components/ui/icons'
|
||
|
||
// ─── Constants & Types ─────────────────────────────────────────
|
||
|
||
const VIEWPORT_EDGE_GAP = 8
|
||
const DEFAULT_MAX_HEIGHT = 280
|
||
|
||
export interface SelectOption {
|
||
value: string
|
||
label: string
|
||
description?: string
|
||
icon?: string
|
||
disabled?: boolean
|
||
}
|
||
|
||
export interface CustomSelectProps {
|
||
options: SelectOption[]
|
||
value?: string
|
||
onChange: (value: string) => void
|
||
placeholder?: string
|
||
disabled?: boolean
|
||
className?: string
|
||
}
|
||
|
||
// ─── Variant 1: Pill / Solid Card Style ─────────────────────────
|
||
// (最贴近”默认模型配置“卡片的经典风格,四周有饱满的边框与微弱底色)
|
||
|
||
export function SelectVariantCard({
|
||
options,
|
||
value,
|
||
onChange,
|
||
placeholder = '请选择...',
|
||
disabled = false,
|
||
className = '',
|
||
}: CustomSelectProps) {
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||
const panelRef = useRef<HTMLDivElement>(null)
|
||
|
||
const selectedOption = options.find((opt) => opt.value === value)
|
||
|
||
const updatePosition = useCallback(() => {
|
||
if (!triggerRef.current) return
|
||
const rect = triggerRef.current.getBoundingClientRect()
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||
|
||
let openUpward = false
|
||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||
|
||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||
openUpward = true
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||
} else {
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||
}
|
||
|
||
setPanelStyle({
|
||
position: 'fixed',
|
||
left: rect.left,
|
||
width: rect.width,
|
||
maxHeight: currentMaxHeight,
|
||
...(openUpward
|
||
? { bottom: viewportHeight - rect.top + 4 }
|
||
: { top: rect.bottom + 4 }),
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
const target = e.target as Node
|
||
if (triggerRef.current?.contains(target)) return
|
||
if (panelRef.current?.contains(target)) return
|
||
setIsOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
useLayoutEffect(() => {
|
||
if (!isOpen) return
|
||
updatePosition()
|
||
window.addEventListener('resize', updatePosition)
|
||
window.addEventListener('scroll', updatePosition, true)
|
||
return () => {
|
||
window.removeEventListener('resize', updatePosition)
|
||
window.removeEventListener('scroll', updatePosition, true)
|
||
}
|
||
}, [isOpen, updatePosition])
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className={`glass-input-base w-full flex items-center justify-between px-3 py-2.5 transition-all text-left ${isOpen ? 'ring-1 ring-[var(--glass-stroke-active)]' : ''
|
||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-[var(--glass-bg-hover)]'} ${className}`}
|
||
>
|
||
<div className="flex-1 min-w-0 pr-2">
|
||
{selectedOption ? (
|
||
<div className="flex flex-col">
|
||
<span className="text-sm font-medium text-[var(--glass-text-primary)] truncate">
|
||
{selectedOption.label}
|
||
</span>
|
||
{selectedOption.description && (
|
||
<span className="text-[11px] text-[var(--glass-text-tertiary)] truncate mt-0.5">
|
||
{selectedOption.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-sm text-[var(--glass-text-tertiary)]">{placeholder}</span>
|
||
)}
|
||
</div>
|
||
<AppIcon
|
||
name="chevronDown"
|
||
className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''
|
||
}`}
|
||
/>
|
||
</button>
|
||
|
||
{isOpen &&
|
||
createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-xl border border-[var(--glass-stroke-base)] py-1"
|
||
style={panelStyle}
|
||
>
|
||
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
|
||
{options.map((opt) => {
|
||
const isSelected = value === opt.value
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={opt.disabled}
|
||
onClick={() => {
|
||
if (opt.disabled) return
|
||
onChange(opt.value)
|
||
setIsOpen(false)
|
||
}}
|
||
className={`flex items-center w-full px-3 py-2 my-0.5 rounded-lg text-left transition-all ${isSelected
|
||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||
: opt.disabled
|
||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||
}`}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<div className={`text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>
|
||
{opt.label}
|
||
</div>
|
||
{opt.description && (
|
||
<div className={`text-[11px] mt-0.5 ${isSelected ? 'text-[var(--glass-tone-info-fg)] opacity-80' : 'text-[var(--glass-text-tertiary)]'}`}>
|
||
{opt.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isSelected && (
|
||
<AppIcon name="check" className="w-4 h-4 shrink-0 overflow-visible ml-2" />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Variant 2: Minimalist Line / Base Style ────────────────────
|
||
// (底部细线风格,适用于表单密集的区域,突出内容而非边框)
|
||
|
||
export function SelectVariantMinimal({
|
||
options,
|
||
value,
|
||
onChange,
|
||
placeholder = '请选择...',
|
||
disabled = false,
|
||
className = '',
|
||
}: CustomSelectProps) {
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||
const panelRef = useRef<HTMLDivElement>(null)
|
||
|
||
const selectedOption = options.find((opt) => opt.value === value)
|
||
|
||
const updatePosition = useCallback(() => {
|
||
if (!triggerRef.current) return
|
||
const rect = triggerRef.current.getBoundingClientRect()
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||
|
||
let openUpward = false
|
||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||
|
||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||
openUpward = true
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||
} else {
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||
}
|
||
|
||
setPanelStyle({
|
||
position: 'fixed',
|
||
left: rect.left,
|
||
width: rect.width,
|
||
maxHeight: currentMaxHeight,
|
||
...(openUpward
|
||
? { bottom: viewportHeight - rect.top + 4 }
|
||
: { top: rect.bottom + 4 }),
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
const target = e.target as Node
|
||
if (triggerRef.current?.contains(target)) return
|
||
if (panelRef.current?.contains(target)) return
|
||
setIsOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
useLayoutEffect(() => {
|
||
if (!isOpen) return
|
||
updatePosition()
|
||
window.addEventListener('resize', updatePosition)
|
||
window.addEventListener('scroll', updatePosition, true)
|
||
return () => {
|
||
window.removeEventListener('resize', updatePosition)
|
||
window.removeEventListener('scroll', updatePosition, true)
|
||
}
|
||
}, [isOpen, updatePosition])
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className={`group flex items-center justify-between w-full py-2 px-1 text-left transition-all border-b border-[var(--glass-stroke-base)] ${isOpen ? 'border-[var(--glass-text-primary)]' : 'hover:border-[var(--glass-text-secondary)]'
|
||
} ${disabled ? 'opacity-50 cursor-not-allowed border-[var(--glass-stroke-subtle)]' : 'cursor-pointer'} ${className}`}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
{selectedOption ? (
|
||
<span className="text-sm font-medium text-[var(--glass-text-primary)] truncate">
|
||
{selectedOption.label}
|
||
</span>
|
||
) : (
|
||
<span className="text-sm text-[var(--glass-text-tertiary)]">{placeholder}</span>
|
||
)}
|
||
</div>
|
||
<AppIcon
|
||
name="chevronDown"
|
||
className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-all ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'group-hover:text-[var(--glass-text-secondary)]'
|
||
}`}
|
||
/>
|
||
</button>
|
||
|
||
{isOpen &&
|
||
createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] py-1 bg-gradient-to-b from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-md"
|
||
style={panelStyle}
|
||
>
|
||
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
|
||
{options.map((opt) => {
|
||
const isSelected = value === opt.value
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={opt.disabled}
|
||
onClick={() => {
|
||
if (opt.disabled) return
|
||
onChange(opt.value)
|
||
setIsOpen(false)
|
||
}}
|
||
className={`flex items-center w-full px-4 py-2.5 my-0.5 rounded-md text-left transition-all ${isSelected
|
||
? 'bg-[var(--glass-text-primary)] text-white dark:text-black dark:bg-[var(--glass-text-primary)] shadow-sm'
|
||
: opt.disabled
|
||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)]'
|
||
}`}
|
||
>
|
||
<span className={`flex-1 min-w-0 text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>
|
||
{opt.label}
|
||
</span>
|
||
{isSelected && (
|
||
<AppIcon name="check" className="w-4 h-4 shrink-0 overflow-visible ml-2" />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Variant 3: Ghost / Lightweight ─────────────────────────────
|
||
// (背景透明,只有hover态有色块,适合用于工具栏、筛选器等紧凑小巧的场景)
|
||
|
||
export function SelectVariantGhost({
|
||
options,
|
||
value,
|
||
onChange,
|
||
placeholder = '请选择...',
|
||
disabled = false,
|
||
className = '',
|
||
}: CustomSelectProps) {
|
||
const [isOpen, setIsOpen] = useState(false)
|
||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})
|
||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||
const panelRef = useRef<HTMLDivElement>(null)
|
||
|
||
const selectedOption = options.find((opt) => opt.value === value)
|
||
|
||
const updatePosition = useCallback(() => {
|
||
if (!triggerRef.current) return
|
||
const rect = triggerRef.current.getBoundingClientRect()
|
||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||
|
||
let openUpward = false
|
||
let currentMaxHeight = DEFAULT_MAX_HEIGHT
|
||
|
||
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
|
||
openUpward = true
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)
|
||
} else {
|
||
currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)
|
||
}
|
||
|
||
setPanelStyle({
|
||
position: 'fixed',
|
||
left: rect.left,
|
||
width: Math.max(rect.width, 180), // Ghost往往自身较小,给下拉留点宽度
|
||
maxHeight: currentMaxHeight,
|
||
...(openUpward
|
||
? { bottom: viewportHeight - rect.top + 4 }
|
||
: { top: rect.bottom + 4 }),
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
function handleClickOutside(e: MouseEvent) {
|
||
const target = e.target as Node
|
||
if (triggerRef.current?.contains(target)) return
|
||
if (panelRef.current?.contains(target)) return
|
||
setIsOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', handleClickOutside)
|
||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||
}, [])
|
||
|
||
useLayoutEffect(() => {
|
||
if (!isOpen) return
|
||
updatePosition()
|
||
window.addEventListener('resize', updatePosition)
|
||
window.addEventListener('scroll', updatePosition, true)
|
||
return () => {
|
||
window.removeEventListener('resize', updatePosition)
|
||
window.removeEventListener('scroll', updatePosition, true)
|
||
}
|
||
}, [isOpen, updatePosition])
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className={`inline-flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg transition-colors text-left ${isOpen ? 'bg-[var(--glass-bg-hover)]' : 'hover:bg-[var(--glass-bg-surface-strong)]'
|
||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${className}`}
|
||
>
|
||
<span className={`text-[13px] whitespace-nowrap overflow-hidden text-ellipsis ${selectedOption ? 'font-medium text-[var(--glass-text-secondary)]' : 'text-[var(--glass-text-tertiary)]'}`}>
|
||
{selectedOption ? selectedOption.label : placeholder}
|
||
</span>
|
||
<AppIcon
|
||
name="chevronDown"
|
||
className={`w-3.5 h-3.5 mt-0.5 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''
|
||
}`}
|
||
/>
|
||
</button>
|
||
|
||
{isOpen &&
|
||
createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-lg border border-[var(--glass-stroke-subtle)] py-1"
|
||
style={panelStyle}
|
||
>
|
||
<div className="overflow-y-auto app-scrollbar p-1 max-h-full space-y-0.5">
|
||
{options.map((opt) => {
|
||
const isSelected = value === opt.value
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={opt.disabled}
|
||
onClick={() => {
|
||
if (opt.disabled) return
|
||
onChange(opt.value)
|
||
setIsOpen(false)
|
||
}}
|
||
className={`flex items-center w-full px-2.5 py-1.5 rounded-md text-left transition-colors ${isSelected
|
||
? 'bg-[var(--glass-bg-active)] text-[var(--glass-text-primary)]'
|
||
: opt.disabled
|
||
? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'
|
||
: 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-secondary)]'
|
||
}`}
|
||
>
|
||
<span className={`flex-1 min-w-0 text-[13px] ${isSelected ? 'font-medium' : ''}`}>
|
||
{opt.label}
|
||
</span>
|
||
{isSelected && (
|
||
<AppIcon name="check" className="w-3.5 h-3.5 shrink-0 overflow-visible ml-2 text-[var(--glass-text-primary)]" />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
)
|
||
}
|