fix(select): 修复选择组件的状态管理和事件处理

This commit is contained in:
Jeff Gu committed 2026-04-10 13:17:37 +08:00
1 parent f0e3a349eb
commit 0d6983fda4
5 files changed
+175 -114

No files matched your search

-1
View File
@@ -27,7 +27,6 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "^5.97.0", "@tanstack/react-query": "^5.97.0",
+16 -1
View File
@@ -45,6 +45,7 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
className, className,
dropdownClassName, dropdownClassName,
inputClassName, inputClassName,
onClick,
onFocus, onFocus,
onBlur, onBlur,
disabled, disabled,
@@ -54,6 +55,7 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
) => { ) => {
const containerRef = React.useRef<HTMLDivElement | null>(null) const containerRef = React.useRef<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
React.useEffect(() => { React.useEffect(() => {
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
@@ -66,7 +68,13 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
return () => document.removeEventListener('mousedown', handlePointerDown) return () => document.removeEventListener('mousedown', handlePointerDown)
}, []) }, [])
const query = normalizeText(value) React.useEffect(() => {
if (!isOpen) {
setSearchQuery('')
}
}, [isOpen])
const query = normalizeText(searchQuery)
const dedupedOptions = options.filter((option, index) => { const dedupedOptions = options.filter((option, index) => {
return options.findIndex((candidate) => candidate.value === option.value) === index return options.findIndex((candidate) => candidate.value === option.value) === index
}) })
@@ -84,7 +92,13 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
ref={ref} ref={ref}
disabled={disabled} disabled={disabled}
value={value} value={value}
onClick={(event) => {
setSearchQuery('')
setIsOpen(true)
onClick?.(event)
}}
onFocus={(event) => { onFocus={(event) => {
setSearchQuery('')
setIsOpen(true) setIsOpen(true)
onFocus?.(event) onFocus?.(event)
}} }}
@@ -93,6 +107,7 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
}} }}
onChange={(event) => { onChange={(event) => {
onChange(event.target.value) onChange(event.target.value)
setSearchQuery(event.target.value)
setIsOpen(true) setIsOpen(true)
}} }}
className={inputClassName} className={inputClassName}
+148 -106
View File
@@ -1,71 +1,70 @@
import * as React from "react"; import * as React from 'react'
import * as SelectPrimitive from "@radix-ui/react-select"; import { Check, ChevronDown } from 'lucide-react'
import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils";
type NativeSelectProps = Omit<React.ComponentProps<"select">, "children" | "onChange" | "size">; type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'children' | 'onChange' | 'size'>
interface SelectProps extends NativeSelectProps { interface SelectProps extends NativeSelectProps {
children: React.ReactNode; children: React.ReactNode
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void; onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void
placeholder?: string; placeholder?: string
contentClassName?: string; contentClassName?: string
} }
interface SelectOption { interface SelectOption {
value: string; value: string
label: React.ReactNode; label: React.ReactNode
disabled?: boolean; disabled?: boolean
} }
const TRIGGER_BASE_CLASS = const TRIGGER_BASE_CLASS =
"group relative flex h-10 w-full items-center rounded-lg border border-input/90 bg-card/90 px-3 py-2 pr-12 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-primary/25 hover:bg-card focus-visible:border-primary/55 focus-visible:ring-4 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground"; 'group relative flex h-10 w-full items-center rounded-lg border border-input bg-transparent px-3 py-2 pr-10 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-muted-foreground/30 focus-visible:border-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
const CONTENT_BASE_CLASS = const CONTENT_BASE_CLASS =
"z-50 max-h-72 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-border/70 bg-popover/95 p-1.5 text-popover-foreground shadow-md backdrop-blur-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 dark:shadow-[0_24px_70px_-24px_hsl(0_0%_0%_/_0.8)]"; 'absolute z-50 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-lg'
const ITEM_BASE_CLASS = const ITEM_BASE_CLASS =
"relative flex w-full select-none items-center rounded-lg px-3 py-2 pr-9 text-xs text-foreground outline-none transition-all duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-primary/10 data-[highlighted]:text-foreground data-[state=checked]:bg-primary/8"; 'relative flex w-full select-none items-center px-3 py-2 pr-9 text-left text-sm text-foreground outline-none transition-colors duration-150 hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-45'
const isElementOfType = ( const isElementOfType = (
child: React.ReactNode, child: React.ReactNode,
type: "option" | "optgroup", type: 'option' | 'optgroup'
): child is React.ReactElement<React.HTMLAttributes<HTMLElement>> => React.isValidElement(child) && child.type === type; ): child is React.ReactElement<React.HTMLAttributes<HTMLElement>> => React.isValidElement(child) && child.type === type
const extractOptions = (children: React.ReactNode): SelectOption[] => { const extractOptions = (children: React.ReactNode): SelectOption[] => {
const options: SelectOption[] = []; const options: SelectOption[] = []
React.Children.forEach(children, (child) => { React.Children.forEach(children, (child) => {
if (!child) return; if (!child) return
if (React.isValidElement(child) && child.type === React.Fragment) { if (React.isValidElement(child) && child.type === React.Fragment) {
options.push(...extractOptions(child.props.children)); options.push(...extractOptions(child.props.children))
return; return
} }
if (isElementOfType(child, "optgroup")) { if (isElementOfType(child, 'optgroup')) {
options.push(...extractOptions(child.props.children)); options.push(...extractOptions(child.props.children))
return; return
} }
if (!isElementOfType(child, "option")) { if (!isElementOfType(child, 'option')) {
return; return
} }
const rawValue = child.props.value; const rawValue = child.props.value
if (rawValue == null) { if (rawValue == null) {
return; return
} }
options.push({ options.push({
value: String(rawValue), value: String(rawValue),
label: child.props.children, label: child.props.children,
disabled: Boolean(child.props.disabled), disabled: Boolean(child.props.disabled),
}); })
}); })
return options; return options
}; }
const Select = React.forwardRef<HTMLButtonElement, SelectProps>( const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
( (
@@ -86,102 +85,145 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
onBlur, onBlur,
onFocus, onFocus,
onKeyDown, onKeyDown,
placeholder = "请选择", placeholder = '请选择',
"aria-label": ariaLabel, 'aria-label': ariaLabel,
"aria-describedby": ariaDescribedBy, 'aria-describedby': ariaDescribedBy,
"aria-labelledby": ariaLabelledBy, 'aria-labelledby': ariaLabelledBy,
}, },
ref, ref
) => { ) => {
const options = React.useMemo(() => extractOptions(children), [children]); const containerRef = React.useRef<HTMLDivElement | null>(null)
const selectedOptionRef = React.useRef<HTMLButtonElement | null>(null)
const listboxId = React.useId()
const options = React.useMemo(() => extractOptions(children), [children])
const normalizedValue = typeof value === "string" || typeof value === "number" ? String(value) : undefined; const normalizedValue = typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
const normalizedDefaultValue = const normalizedDefaultValue =
typeof defaultValue === "string" || typeof defaultValue === "number" ? String(defaultValue) : undefined; typeof defaultValue === 'string' || typeof defaultValue === 'number' ? String(defaultValue) : undefined
const isControlled = normalizedValue !== undefined
const [internalValue, setInternalValue] = React.useState(normalizedDefaultValue)
const selectedValue = isControlled ? normalizedValue : internalValue
const selectedOption = options.find((option) => option.value === selectedValue)
const hasValue = typeof selectedValue === 'string' && selectedValue.length > 0
const [isOpen, setIsOpen] = React.useState(false)
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
React.useEffect(() => {
if (disabled) {
setIsOpen(false)
}
}, [disabled])
React.useEffect(() => {
if (isOpen) {
selectedOptionRef.current?.scrollIntoView({ block: 'nearest' })
}
}, [isOpen, selectedValue])
const handleValueChange = React.useCallback( const handleValueChange = React.useCallback(
(nextValue: string) => { (nextValue: string) => {
if (!onChange) return; if (!isControlled) {
setInternalValue(nextValue)
}
const syntheticEvent = { if (onChange) {
target: { value: nextValue, name }, const syntheticEvent = {
currentTarget: { value: nextValue, name }, target: { value: nextValue, name },
} as React.ChangeEvent<HTMLSelectElement>; currentTarget: { value: nextValue, name },
} as React.ChangeEvent<HTMLSelectElement>
onChange(syntheticEvent); onChange(syntheticEvent)
}
setIsOpen(false)
}, },
[name, onChange], [isControlled, name, onChange]
); )
return ( return (
<SelectPrimitive.Root <div ref={containerRef} dir={dir} className="relative w-full">
value={normalizedValue} {name && (
defaultValue={normalizedDefaultValue} <input
onValueChange={handleValueChange} type="hidden"
disabled={disabled} name={name}
name={name} value={selectedValue ?? ''}
autoComplete={autoComplete} form={form}
required={required} autoComplete={autoComplete}
form={form} disabled={disabled}
dir={dir} aria-hidden="true"
> data-required={required ? 'true' : undefined}
<SelectPrimitive.Trigger />
)}
<button
ref={ref} ref={ref}
id={id} id={id}
className={cn(TRIGGER_BASE_CLASS, className)} type="button"
disabled={disabled}
className={cn(TRIGGER_BASE_CLASS, !hasValue && 'text-muted-foreground', className)}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby={ariaDescribedBy} aria-describedby={ariaDescribedBy}
aria-labelledby={ariaLabelledBy} aria-labelledby={ariaLabelledBy}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
onClick={() => setIsOpen((open) => !open)}
onBlur={onBlur} onBlur={onBlur}
onFocus={onFocus} onFocus={onFocus}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<SelectPrimitive.Value placeholder={placeholder} /> <span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span>
<SelectPrimitive.Icon asChild> <span className="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-muted-foreground transition-all duration-200">
<span className="pointer-events-none absolute right-2.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center text-muted-foreground transition-all duration-200 "> <ChevronDown className={cn('h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180')} />
<ChevronDown className="h-5 w-5 transition-transform duration-200 group-data-[state=open]:rotate-180" /> </span>
</span> </button>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal> {isOpen && !disabled && (
<SelectPrimitive.Content <div id={listboxId} role="listbox" className={cn(CONTENT_BASE_CLASS, contentClassName)}>
className={cn(CONTENT_BASE_CLASS, contentClassName)} <div className="max-h-72 overflow-y-auto py-1">
position="popper" {options.map((option) => {
sideOffset={10} const isSelected = option.value === selectedValue
collisionPadding={12}
>
<SelectPrimitive.ScrollUpButton className="flex h-8 cursor-default items-center justify-center rounded-md text-muted-foreground">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-0.5"> return (
{options.map((option) => ( <button
<SelectPrimitive.Item key={option.value}
key={option.value} ref={isSelected ? selectedOptionRef : undefined}
value={option.value} type="button"
disabled={option.disabled} role="option"
className={ITEM_BASE_CLASS} aria-selected={isSelected}
textValue={typeof option.label === "string" ? option.label : option.value} disabled={option.disabled}
> className={cn(ITEM_BASE_CLASS, isSelected && 'bg-muted/80')}
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText> onMouseDown={(event) => event.preventDefault()}
<SelectPrimitive.ItemIndicator className="absolute right-3 inline-flex items-center justify-center text-primary"> onClick={() => handleValueChange(option.value)}
<Check className="h-4 w-4" /> >
</SelectPrimitive.ItemIndicator> <span className="truncate">{option.label}</span>
</SelectPrimitive.Item> {isSelected && (
))} <span className="absolute right-3 inline-flex items-center justify-center text-primary">
</SelectPrimitive.Viewport> <Check className="h-4 w-4" />
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}
)
<SelectPrimitive.ScrollDownButton className="flex h-8 cursor-default items-center justify-center rounded-md text-muted-foreground"> Select.displayName = 'Select'
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
},
);
Select.displayName = "Select"; export { Select }
export { Select };
+1 -1
View File
@@ -8,7 +8,7 @@
"dev:web": "bun run --filter frontend dev", "dev:web": "bun run --filter frontend dev",
"dev:worker": "wrangler dev --config wrangler.local.jsonc --local --host localhost", "dev:worker": "wrangler dev --config wrangler.local.jsonc --local --host localhost",
"build": "bun run --filter frontend build", "build": "bun run --filter frontend build",
"deploy": "bun run build && wrangler deploy --config wrangler.jsonc --minify", "deploy": "bun run build && wrangler deploy --config wrangler.jsonc",
"cf-typegen": "wrangler types --config wrangler.jsonc --include-env=false" "cf-typegen": "wrangler types --config wrangler.jsonc --include-env=false"
}, },
"repository": { "repository": {
+10 -5
View File
@@ -3,9 +3,14 @@
"name": "one-api-workers", "name": "one-api-workers",
"main": "src/index.ts", "main": "src/index.ts",
"compatibility_date": "2025-04-28", "compatibility_date": "2025-04-28",
"workers_dev": false, "workers_dev": true,
"vars": { "vars": {},
"ADMIN_TOKEN": "CHANGE_ME", "secrets": {
"required": [
"ADMIN_TOKEN",
"CF_API_TOKEN",
"CF_ACCOUNT_ID"
]
}, },
"assets": { "assets": {
"directory": "./public", "directory": "./public",
@@ -16,8 +21,8 @@
"d1_databases": [ "d1_databases": [
{ {
"binding": "DB", "binding": "DB",
"database_name": "one-api", "database_name": "one-api-workers",
"database_id": "00000000-0000-0000-0000-000000000000" "database_id": ""
} }
] ]
} }