mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat(quota-toolbar): implement search functionality and separate toolbar layout
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* tabs 与排序同一行:tabs 会自己横向滚动,所以排序固定不压缩。 */
|
||||
/* 提供商导航独占一行,保留原生横向滚动。 */
|
||||
|
||||
.tabsRow {
|
||||
display: flex;
|
||||
@@ -36,28 +36,139 @@
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 340px;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--border-hover);
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 42px 0 35px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
appearance: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button,
|
||||
&::-webkit-search-decoration {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.clearSearch {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.sort {
|
||||
flex: 0 0 auto;
|
||||
min-width: 148px;
|
||||
max-width: 100%;
|
||||
|
||||
button[aria-haspopup='listbox'] {
|
||||
height: 36px;
|
||||
border-color: transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:focus-visible,
|
||||
&[aria-expanded='true'] {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.clearSearch:hover,
|
||||
.sort button[aria-haspopup='listbox']:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clearSearch:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.tabsRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.search {
|
||||
flex: 1 1 220px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.sort {
|
||||
width: 100%;
|
||||
.clearSearch {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.sort button[aria-haspopup='listbox'] {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconSearch, IconX } from '@/components/ui/icons';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
@@ -76,6 +76,7 @@ export function QuotaPage() {
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// 页头 + tabs 的入场级联(标题 → meta → 动作 → tabs,级差 70ms)
|
||||
const revealRef = useRevealGroup<HTMLDivElement>();
|
||||
|
||||
@@ -323,8 +324,7 @@ export function QuotaPage() {
|
||||
/>
|
||||
|
||||
<section className={styles.workbench}>
|
||||
{/* tabs + 排序作为一个整体入场(useRevealGroup 会给每个 [data-reveal]
|
||||
后代加一级级差,所以排序控件放在同一个节点里而不是做兄弟) */}
|
||||
{/* 提供商导航与搜索工具栏分层,避免不同控件争夺视觉焦点。 */}
|
||||
<div className={styles.tabsRow} data-reveal>
|
||||
<ProviderTabs
|
||||
types={TAB_IDS}
|
||||
@@ -333,6 +333,35 @@ export function QuotaPage() {
|
||||
resolvedTheme={resolvedTheme}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.search}>
|
||||
<IconSearch size={16} className={styles.searchIcon} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder={t('quota_management.search_placeholder')}
|
||||
aria-label={t('quota_management.search_label')}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearSearch}
|
||||
aria-label={t('quota_management.search_clear')}
|
||||
title={t('quota_management.search_clear')}
|
||||
onClick={() => {
|
||||
handleSearchChange('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<IconX size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sort}>
|
||||
<Select
|
||||
value={sortMode}
|
||||
@@ -344,16 +373,6 @@ export function QuotaPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
placeholder={t('quota_management.search_placeholder')}
|
||||
aria-label={t('quota_management.search_label')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorBanner} role="alert">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync('src/features/quota/QuotaPage.tsx', 'utf8');
|
||||
const styles = readFileSync('src/features/quota/QuotaPage.module.scss', 'utf8');
|
||||
|
||||
describe('quota toolbar presentation contracts', () => {
|
||||
test('uses a named, explicit clear action and restores input focus', () => {
|
||||
expect(source).toContain('type="search"');
|
||||
expect(source).toContain('ref={searchInputRef}');
|
||||
expect(source).toContain('{search && (');
|
||||
expect(source).toContain("aria-label={t('quota_management.search_clear')}");
|
||||
expect(source).toContain(
|
||||
"handleSearchChange('');\n searchInputRef.current?.focus();"
|
||||
);
|
||||
expect(source).toContain('<IconX size={14} aria-hidden="true" />');
|
||||
expect(styles).toMatch(/&::-webkit-search-cancel-button,[\s\S]*?appearance: none;/);
|
||||
});
|
||||
|
||||
test('groups search and sorting separately from provider navigation', () => {
|
||||
const toolbarStart = source.indexOf('<div className={styles.toolbar}>');
|
||||
const searchStart = source.indexOf('<div className={styles.search}>');
|
||||
const sortStart = source.indexOf('<div className={styles.sort}>');
|
||||
expect(toolbarStart).toBeGreaterThan(source.indexOf('<ProviderTabs'));
|
||||
expect(searchStart).toBeGreaterThan(toolbarStart);
|
||||
expect(sortStart).toBeGreaterThan(searchStart);
|
||||
expect(styles).toMatch(/\.toolbar\s*\{[^}]*flex-wrap: wrap;/);
|
||||
expect(styles).toContain('&:focus-within');
|
||||
expect(styles).toContain('&:focus-visible');
|
||||
expect(styles).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user