fix(select): 修复选择组件的状态管理和事件处理
This commit is contained in:
1 parent
f0e3a349eb
commit
0d6983fda4
5 files changed
+175
-114
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user