chore: checkpoint admin editor and perf work
This commit is contained in:
@@ -1,18 +1,473 @@
|
||||
import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-xl border border-input bg-background/80 px-3 py-2 text-sm shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
type NativeSelectProps = React.ComponentProps<'select'>
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
label: React.ReactNode
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
type MenuPlacement = 'top' | 'bottom'
|
||||
|
||||
function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] == null ? '' : String(value[0])
|
||||
}
|
||||
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
function extractOptions(children: React.ReactNode) {
|
||||
const options: SelectOption[] = []
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child) || child.type !== 'option') {
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: normalizeValue(props.value),
|
||||
label: props.children,
|
||||
disabled: Boolean(props.disabled),
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function getFirstEnabledIndex(options: SelectOption[]) {
|
||||
return options.findIndex((option) => !option.disabled)
|
||||
}
|
||||
|
||||
function getLastEnabledIndex(options: SelectOption[]) {
|
||||
for (let index = options.length - 1; index >= 0; index -= 1) {
|
||||
if (!options[index]?.disabled) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
|
||||
if (options.length === 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let index = currentIndex
|
||||
|
||||
for (let step = 0; step < options.length; step += 1) {
|
||||
index = (index + direction + options.length) % options.length
|
||||
if (!options[index]?.disabled) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
id,
|
||||
onBlur,
|
||||
onClick,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const options = React.useMemo(() => extractOptions(children), [children])
|
||||
const isControlled = value !== undefined
|
||||
const initialValue = React.useMemo(() => {
|
||||
if (defaultValue !== undefined) {
|
||||
return normalizeValue(defaultValue)
|
||||
}
|
||||
|
||||
return options[0]?.value ?? ''
|
||||
}, [defaultValue, options])
|
||||
|
||||
const [internalValue, setInternalValue] = React.useState(initialValue)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
|
||||
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
|
||||
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
|
||||
const menuRef = React.useRef<HTMLDivElement>(null)
|
||||
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
|
||||
const menuId = React.useId()
|
||||
|
||||
const currentValue = isControlled ? normalizeValue(value) : internalValue
|
||||
const selectedIndex = options.findIndex((option) => option.value === currentValue)
|
||||
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
|
||||
setInternalValue(options[0]?.value ?? '')
|
||||
}
|
||||
}, [internalValue, isControlled, options])
|
||||
|
||||
const updateMenuPosition = React.useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const viewportPadding = 12
|
||||
const gutter = 6
|
||||
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
|
||||
const spaceAbove = rect.top - viewportPadding
|
||||
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
|
||||
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
|
||||
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
|
||||
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
|
||||
|
||||
setMenuPlacement(openToTop ? 'top' : 'bottom')
|
||||
setMenuStyle(
|
||||
openToTop
|
||||
? {
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
bottom: window.innerHeight - rect.top + gutter,
|
||||
}
|
||||
: {
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
top: rect.bottom + gutter,
|
||||
},
|
||||
)
|
||||
}, [options.length])
|
||||
|
||||
const setOpenWithHighlight = React.useCallback(
|
||||
(nextOpen: boolean, preferredIndex?: number) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextOpen) {
|
||||
const fallbackIndex =
|
||||
preferredIndex ??
|
||||
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
|
||||
? selectedIndex
|
||||
: getFirstEnabledIndex(options))
|
||||
|
||||
setHighlightedIndex(fallbackIndex)
|
||||
updateMenuPosition()
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
},
|
||||
[disabled, options, selectedIndex, updateMenuPosition],
|
||||
)
|
||||
|
||||
const commitValue = React.useCallback(
|
||||
(nextIndex: number) => {
|
||||
const option = options[nextIndex]
|
||||
const nativeSelect = nativeSelectRef.current
|
||||
|
||||
if (!option || option.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(option.value)
|
||||
}
|
||||
|
||||
if (nativeSelect && currentValue !== option.value) {
|
||||
nativeSelect.value = option.value
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
window.requestAnimationFrame(() => {
|
||||
triggerRef.current?.focus()
|
||||
})
|
||||
},
|
||||
[currentValue, isControlled, options],
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
|
||||
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
const nextIndex =
|
||||
selectedIndex >= 0
|
||||
? getNextEnabledIndex(options, selectedIndex, 1)
|
||||
: getFirstEnabledIndex(options)
|
||||
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
|
||||
return
|
||||
}
|
||||
|
||||
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
|
||||
return
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
const nextIndex =
|
||||
selectedIndex >= 0
|
||||
? getNextEnabledIndex(options, selectedIndex, -1)
|
||||
: getLastEnabledIndex(options)
|
||||
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
|
||||
return
|
||||
}
|
||||
|
||||
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
|
||||
return
|
||||
}
|
||||
case 'Home': {
|
||||
event.preventDefault()
|
||||
const firstIndex = getFirstEnabledIndex(options)
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true, firstIndex)
|
||||
return
|
||||
}
|
||||
setHighlightedIndex(firstIndex)
|
||||
return
|
||||
}
|
||||
case 'End': {
|
||||
event.preventDefault()
|
||||
const lastIndex = getLastEnabledIndex(options)
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true, lastIndex)
|
||||
return
|
||||
}
|
||||
setHighlightedIndex(lastIndex)
|
||||
return
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (highlightedIndex >= 0) {
|
||||
commitValue(highlightedIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'Escape': {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
case 'Tab': {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
}, [open, updateMenuPosition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleWindowChange = () => updateMenuPosition()
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
window.addEventListener('resize', handleWindowChange)
|
||||
window.addEventListener('scroll', handleWindowChange, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
window.removeEventListener('resize', handleWindowChange)
|
||||
window.removeEventListener('scroll', handleWindowChange, true)
|
||||
}
|
||||
}, [open, updateMenuPosition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || highlightedIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlightedIndex, open])
|
||||
|
||||
const triggerClasses = cn(
|
||||
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
|
||||
className,
|
||||
)
|
||||
|
||||
const menu = open && menuStyle
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
aria-orientation="vertical"
|
||||
className={cn(
|
||||
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
|
||||
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
|
||||
)}
|
||||
id={menuId}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
style={menuStyle}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="max-h-full overflow-y-auto pr-0.5">
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === currentValue
|
||||
const highlighted = index === highlightedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${option.value}-${index}`}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node
|
||||
}}
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
||||
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
|
||||
selected
|
||||
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
|
||||
: highlighted
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
disabled={option.disabled}
|
||||
onClick={() => commitValue(index)}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) {
|
||||
setHighlightedIndex(index)
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<select
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
ref={(node) => {
|
||||
nativeSelectRef.current = node
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(node)
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = node
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
value={isControlled ? currentValue : internalValue}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
<button
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={triggerClasses}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
disabled={disabled}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerRef.current?.focus()
|
||||
setOpenWithHighlight(!open)
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={triggerRef}
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{menu}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user