'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({}) const triggerRef = useRef(null) const panelRef = useRef(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 ( <> {isOpen && createPortal(
{options.map((opt) => { const isSelected = value === opt.value return ( ) })}
, 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({}) const triggerRef = useRef(null) const panelRef = useRef(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 ( <> {isOpen && createPortal(
{options.map((opt) => { const isSelected = value === opt.value return ( ) })}
, 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({}) const triggerRef = useRef(null) const panelRef = useRef(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 ( <> {isOpen && createPortal(
{options.map((opt) => { const isSelected = value === opt.value return ( ) })}
, document.body )} ) }