feat: polish reporting templates and shared controls

This commit is contained in:
hectorzhao
2026-07-15 16:31:49 +08:00
parent 28fb8e9038
commit a7a4e8d9f6
21 changed files with 488 additions and 119 deletions
+65 -25
View File
@@ -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}