refactor(select): unify dropdown implementations

This commit is contained in:
Supra4E8C
2026-02-14 12:50:03 +08:00
parent 32b576123c
commit faadc3ea3e
5 changed files with 58 additions and 276 deletions

View File

@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
@@ -81,120 +81,6 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
}
type ToastSelectOption = { value: string; label: string };
function ToastSelect({
value,
options,
disabled,
ariaLabel,
onChange,
}: {
value: string;
options: ReadonlyArray<ToastSelectOption>;
disabled?: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedOption = options.find((opt) => opt.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<button
type="button"
className="input"
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
width: '100%',
appearance: 'none',
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{selectedOption?.label ?? ''}
</span>
<IconChevronDown size={16} style={{ opacity: 0.6, flex: '0 0 auto' }} />
</button>
{open && !disabled && (
<div
role="listbox"
aria-label={ariaLabel}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 1000,
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: 12,
padding: 6,
boxShadow: 'var(--shadow)',
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 260,
overflowY: 'auto',
}}
>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
style={{
padding: '10px 12px',
borderRadius: 10,
border: active
? '1px solid rgba(139, 134, 128, 0.5)'
: '1px solid var(--border-color)',
background: active ? 'rgba(139, 134, 128, 0.12)' : 'var(--bg-primary)',
color: 'var(--text-primary)',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
function ApiKeysCardEditor({
value,
disabled,
@@ -530,7 +416,7 @@ function PayloadRulesEditor({
>
{protocolFirst ? (
<>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -558,7 +444,7 @@ function PayloadRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -600,7 +486,7 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={param.valueType}
options={payloadValueTypeOptions}
disabled={disabled}
@@ -743,7 +629,7 @@ function PayloadFilterRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -996,7 +882,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
/>
<div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
<ToastSelect
<Select
value={values.routingStrategy}
options={[
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },

View File

@@ -4,6 +4,9 @@
position: relative;
display: inline-flex;
align-items: center;
}
.wrapFullWidth {
width: 100%;
}

View File

@@ -9,37 +9,56 @@ export interface SelectOption {
interface SelectProps {
value: string;
options: SelectOption[];
options: ReadonlyArray<SelectOption>;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
fullWidth?: boolean;
}
export function Select({ value, options, onChange, placeholder, className }: SelectProps) {
export function Select({
value,
options,
onChange,
placeholder,
className,
disabled = false,
ariaLabel,
fullWidth = true
}: SelectProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
}, [disabled, open]);
const isOpen = open && !disabled;
const selected = options.find((o) => o.value === value);
const displayText = selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;
return (
<div className={`${styles.wrap} ${className ?? ''}`} ref={wrapRef}>
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
type="button"
className={styles.trigger}
onClick={() => setOpen((prev) => !prev)}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={open}
aria-expanded={isOpen}
aria-label={ariaLabel}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
@@ -48,8 +67,8 @@ export function Select({ value, options, onChange, placeholder, className }: Sel
<IconChevronDown size={14} />
</span>
</button>
{open && (
<div className={styles.dropdown} role="listbox">
{isOpen && (
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
{options.map((opt) => {
const active = opt.value === value;
return (

View File

@@ -44,100 +44,8 @@
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.timeRangeSelectControl {
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}
.pageTitle {

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,7 +13,7 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore, useConfigStore } from '@/stores';
@@ -120,19 +120,6 @@ export function UsagePage() {
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
@@ -156,6 +143,15 @@ export function UsagePage() {
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey)
})),
[t]
);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
@@ -238,44 +234,14 @@ export function UsagePage() {
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
<Select
value={timeRange}
options={timeRangeOptions}
onChange={(value) => setTimeRange(value as UsageTimeRange)}
className={styles.timeRangeSelectControl}
ariaLabel={t('usage_stats.range_filter')}
fullWidth={false}
/>
</div>
<Button
variant="secondary"