feat(Select): improve dropdown positioning and style management

This commit is contained in:
Supra4E8C
2026-03-27 18:36:38 +08:00
Unverified
parent 4587df2bdb
commit 1318cfaf86
2 changed files with 194 additions and 61 deletions
+2 -5
View File
@@ -67,11 +67,7 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
@@ -83,6 +79,7 @@
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.option {
+192 -56
View File
@@ -1,4 +1,14 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties
} from 'react';
import { createPortal } from 'react-dom';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
@@ -21,6 +31,51 @@ interface SelectProps {
id?: string;
}
const VIEWPORT_MARGIN = 8;
const DROPDOWN_OFFSET = 6;
const DROPDOWN_MAX_HEIGHT = 240;
const DROPDOWN_Z_INDEX = 2010;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
const left = clamp(
rect.left,
VIEWPORT_MARGIN,
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const direction =
spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
const maxHeight = Math.max(
0,
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
);
return direction === 'down'
? {
position: 'fixed',
top: rect.bottom + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
}
: {
position: 'fixed',
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
};
};
export function Select({
value,
options,
@@ -40,17 +95,78 @@ export function Select({
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const wrapRef = useRef<HTMLDivElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
const isOpen = open && !disabled;
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
const target = event.target as Node;
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const updateDropdownStyle = useCallback(() => {
if (!wrapRef.current) return;
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
}, []);
const scheduleDropdownStyleUpdate = useCallback(() => {
if (typeof window === 'undefined') return;
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updateDropdownStyle();
});
}, [updateDropdownStyle]);
useLayoutEffect(() => {
if (!isOpen) {
if (rafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
updateDropdownStyle();
const handleViewportChange = () => {
scheduleDropdownStyleUpdate();
};
const resizeObserver =
typeof ResizeObserver !== 'undefined' && wrapRef.current
? new ResizeObserver(() => {
scheduleDropdownStyleUpdate();
})
: null;
if (resizeObserver && wrapRef.current) {
resizeObserver.observe(wrapRef.current);
}
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
resizeObserver?.disconnect();
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
const resolvedHighlightedIndex =
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
@@ -136,60 +252,80 @@ export function Select({
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
);
useEffect(() => {
if (!isOpen || resolvedHighlightedIndex < 0) return;
const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`);
highlightedOption?.scrollIntoView({ block: 'nearest' });
}, [isOpen, resolvedHighlightedIndex, selectId]);
const dropdown =
isOpen && dropdownStyle
? (
<div
ref={dropdownRef}
className={styles.dropdown}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
style={dropdownStyle}
>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)
: null;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
<>
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
</div>
{dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
</>
);
}