Files
waooplus/src/components/ui/select-variants.tsx

450 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
)}
</>
)
}