feat(select): implement custom Select component with dropdown functionality

This commit is contained in:
Supra4E8C
2026-02-14 12:01:11 +08:00
parent bf824f8561
commit 5dce24e3ea
6 changed files with 214 additions and 42 deletions

View File

@@ -0,0 +1,107 @@
@use '../../styles/mixins' as *;
.wrap {
position: relative;
display: inline-flex;
align-items: center;
width: 100%;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
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;
box-sizing: border-box;
&: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);
}
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-tertiary);
}
.triggerIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.dropdown {
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;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option {
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;
flex-shrink: 0;
&:hover {
background: var(--bg-secondary);
}
}
.optionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}

View File

@@ -0,0 +1,75 @@
import { useState, useEffect, useRef } from 'react';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function Select({ value, options, onChange, placeholder, className }: SelectProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) 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]);
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}>
<button
type="button"
className={styles.trigger}
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{open && (
<div className={styles.dropdown} role="listbox">
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
@@ -41,6 +43,14 @@ export function ChartLineSelector({
onChange(newLines);
};
const options = useMemo(
() => [
{ value: 'all', label: t('usage_stats.chart_line_all') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
@@ -66,18 +76,11 @@ export function ChartLineSelector({
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
<Select
value={line}
onChange={(e) => handleChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={(value) => handleChange(index, value)}
/>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}

View File

@@ -277,10 +277,11 @@ export function CredentialStatsCard({
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')}>
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
@@ -326,6 +327,7 @@ export function CredentialStatsCard({
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
@@ -65,6 +66,14 @@ export function PriceSettingsCard({
}
};
const options = useMemo(
() => [
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
@@ -73,18 +82,12 @@ export function PriceSettingsCard({
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<select
<Select
value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)}
className={styles.select}
>
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={handleModelSelect}
placeholder={t('usage_stats.model_price_select_placeholder')}
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>

View File

@@ -741,24 +741,6 @@
}
}
.select {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
}
.pricesList {
display: flex;
flex-direction: column;