refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
6.57 KiB
RainbowBridge
import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { Icon } from '@/components/common/Icon'

export interface SelectOption {
  value: string
  label: string
  icon?: string
  description?: string
}

interface SelectProps {
  options: SelectOption[]
  value: string
  onChange: (value: string) => void
  placeholder?: string
  className?: string
  disabled?: boolean
}

/** 下拉选择组件 —— 适配 Rainbow 主题的 Portal 下拉菜单,支持键盘导航 */
export function Select({
  options,
  value,
  onChange,
  placeholder,
  className,
  disabled = false,
}: SelectProps) {
  const [open, setOpen] = useState(false)
  const [focusedIndex, setFocusedIndex] = useState(-1)
  const [openUpward, setOpenUpward] = useState(false)
  const [dropdownPos, setDropdownPos] = useState({ left: 0, top: 0, width: 0 })
  const containerRef = useRef<HTMLDivElement>(null)
  const dropdownRef = useRef<HTMLDivElement>(null)

  const selected = options.find((o) => o.value === value)

  const close = useCallback(() => {
    setOpen(false)
    setFocusedIndex(-1)
  }, [])

  const handleOpen = useCallback(() => {
    if (disabled) return
    if (!open) {
      if (containerRef.current) {
        const rect = containerRef.current.getBoundingClientRect()
        const spaceBelow = window.innerHeight - rect.bottom
        const upward = spaceBelow < 220
        setOpenUpward(upward)
        setDropdownPos({
          left: rect.left,
          top: upward ? rect.top - 4 : rect.bottom + 4,
          width: rect.width,
        })
      }
    }
    setOpen((v) => !v)
  }, [disabled, open])

  // 点击外部或滚动/缩放时关闭下拉
  useEffect(() => {
    if (!open) return
    const handler = (e: MouseEvent) => {
      const target = e.target as Node
      const inTrigger = containerRef.current?.contains(target) ?? false
      const inDropdown = dropdownRef.current?.contains(target) ?? false
      if (!inTrigger && !inDropdown) {
        close()
      }
    }
    document.addEventListener('mousedown', handler)
    window.addEventListener('scroll', (e: Event) => {
      if (dropdownRef.current?.contains(e.target as Node)) return
      close()
    }, { capture: true })
    window.addEventListener('resize', close)
    return () => {
      document.removeEventListener('mousedown', handler)
      window.removeEventListener('scroll', close, { capture: true })
      window.removeEventListener('resize', close)
    }
  }, [open, close])

  const handleKeyDown = (e: KeyboardEvent) => {
    if (disabled) return

    switch (e.key) {
      case 'Enter':
      case ' ':
        e.preventDefault()
        if (!open) {
          handleOpen()
          setFocusedIndex(options.findIndex((o) => o.value === value))
        } else if (focusedIndex >= 0) {
          onChange(options[focusedIndex].value)
          close()
        }
        break
      case 'ArrowDown':
        e.preventDefault()
        if (!open) {
          setOpen(true)
          setFocusedIndex(options.findIndex((o) => o.value === value))
        } else {
          setFocusedIndex((i) => (i + 1) % options.length)
        }
        break
      case 'ArrowUp':
        e.preventDefault()
        if (open) {
          setFocusedIndex((i) => (i - 1 + options.length) % options.length)
        }
        break
      case 'Escape':
        e.preventDefault()
        close()
        break
    }
  }

  return (
    <div ref={containerRef} className={cn('relative', className)}>
      <button
        type="button"
        onClick={handleOpen}
        onKeyDown={handleKeyDown}
        disabled={disabled}
        className={cn(
          'flex items-center justify-between w-full rounded-lg border min-h-[36px]',
          'bg-[var(--color-surface-0)] text-sm',
          'px-3 py-2 text-left transition-colors',
          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-brand-500)]/55',
          open
            ? 'border-[color:var(--color-brand-500)] ring-1 ring-[color:var(--color-brand-500)]/50'
            : 'border-[var(--color-border-default)] hover:border-[var(--color-border-strong)]',
          disabled
            ? 'opacity-50 cursor-not-allowed'
            : 'cursor-pointer',
        )}
      >
        <span className={cn(
          'truncate',
          selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]',
        )}>
          {selected?.label ?? placeholder ?? ''}
        </span>
        <Icon
          name="unfold_more"
          size="sm"
          className={cn(
            'ml-2 flex-shrink-0 text-[var(--color-text-tertiary)] transition-transform',
            open && 'text-primary',
          )}
        />
      </button>

      {open && createPortal(
        <div
          ref={dropdownRef}
          style={{
            position: 'fixed',
            left: `${dropdownPos.left}px`,
            top: `${dropdownPos.top}px`,
            width: `${dropdownPos.width}px`,
            transform: openUpward ? 'translateY(-100%)' : '',
          }}
          className={cn(
            'z-50 min-w-[120px]',
            'bg-[var(--color-surface-0)] rounded-xl',
            'border border-[var(--color-border-subtle)]',
            'shadow-[var(--shadow-menu)]',
            'py-1 overflow-auto max-h-60',
            'animate-slide-up',
          )}
        >
          {options.map((opt, idx) => {
            const isSelected = opt.value === value
            const isFocused = idx === focusedIndex
            return (
              <button
                key={opt.value}
                type="button"
                onClick={() => {
                  onChange(opt.value)
                  close()
                }}
                onMouseEnter={() => setFocusedIndex(idx)}
                className={cn(
                  'flex items-center w-full px-3 py-2 text-sm text-left transition-colors',
                  isFocused && 'bg-[var(--color-surface-2)]',
                  isSelected
                    ? 'text-primary font-medium'
                    : 'text-[var(--color-text-primary)]',
                )}
              >
                {opt.icon && (
                  <Icon name={opt.icon} size="sm" className="mr-2 text-[var(--color-text-tertiary)]" />
                )}
                <span className="flex-1 truncate">{opt.label}</span>
                {isSelected && (
                  <Icon name="check" size="sm" className="ml-2 text-primary flex-shrink-0" />
                )}
              </button>
            )
          })}
        </div>,
        document.body,
      )}
    </div>
  )
}