feat(quota-toolbar): implement search functionality and separate toolbar layout

This commit is contained in:
Supra4E8C
2026-09-21 22:48:06 +08:00
parent b9d3f21f3b
commit 4530da271b
3 changed files with 186 additions and 24 deletions
+122 -11
View File
@@ -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;
}
}
+32 -13
View File
@@ -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}
+32
View File
@@ -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)');
});
});