feat: polish reporting templates and shared controls
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
export type SelectOption = {
|
||||
@@ -22,6 +23,7 @@ type SelectProps = NativeSelectProps & {
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
dropdownPortal?: boolean;
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
};
|
||||
|
||||
@@ -39,12 +41,15 @@ export function Select({
|
||||
placeholder,
|
||||
searchable,
|
||||
searchPlaceholder,
|
||||
dropdownPortal = true,
|
||||
required,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectId = id ?? props.name;
|
||||
const rootRef = useRef<HTMLLabelElement | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [portalStyle, setPortalStyle] = useState<CSSProperties | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
|
||||
const selectedValue = value ?? internalValue;
|
||||
@@ -61,7 +66,7 @@ export function Select({
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
@@ -71,6 +76,40 @@ export function Select({
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dropdownPortal) {
|
||||
setPortalStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
|
||||
const spaceAbove = rect.top - gap - 8;
|
||||
const openAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const available = openAbove ? spaceAbove : spaceBelow;
|
||||
setPortalStyle({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: Math.min(320, Math.max(140, available)),
|
||||
...(openAbove
|
||||
? { bottom: window.innerHeight - rect.top + gap, top: 'auto' }
|
||||
: { top: rect.bottom + gap, bottom: 'auto' }),
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [dropdownPortal, open]);
|
||||
|
||||
function selectOption(nextValue: string) {
|
||||
setInternalValue(nextValue);
|
||||
onChange?.({ target: { value: nextValue } });
|
||||
@@ -78,6 +117,28 @@ export function Select({
|
||||
setSearchKeyword('');
|
||||
}
|
||||
|
||||
const dropdown = (
|
||||
<div
|
||||
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <span className="ui-select__empty">无匹配选项</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={['ui-field', className].filter(Boolean).join(' ')}
|
||||
@@ -114,28 +175,7 @@ export function Select({
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-select__dropdown" role="listbox">
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
aria-selected={option.value === selectedValue}
|
||||
key={option.value}
|
||||
onClick={() => selectOption(option.value)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <span className="ui-select__empty">无匹配选项</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{open ? (dropdownPortal ? createPortal(dropdown, document.body) : dropdown) : null}
|
||||
</span>
|
||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||
|
||||
Reference in New Issue
Block a user