feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,449 @@
'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 custom-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 custom-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 custom-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
)}
</>
)
}