feat(frontend): compact FilterBar with disclosure toggle and quick pills

- Quick status pills (Упавшие/В работе/Успешные) always visible
- Search input with flex spacer in same row
- Explicit "Фильтры" disclosure button with filter icon, badge count, chevron
- aria-expanded / aria-controls for accessibility
- Expandable panel: type, status, time range, sort controls
- Remove bottom status bar ("Показано X из Y", "Активно фильтров")
- Update tests for expanded state and quick pill interactions
This commit is contained in:
2026-07-05 15:50:46 +03:00
parent 95308273b3
commit 86ac209615
2 changed files with 293 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
<!-- #region FilterBar [C:3] [TYPE Component] [SEMANTICS reports,filter,task-status-center] -->
<!-- @BRIEF Compact filter toolbar: quick status pills + search in the always-visible row, expandable full-filter panel behind an explicit "Фильтры" disclosure button. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
<!-- @PRE filters is a TaskCenterFilter object. -->
<!-- @POST User changes call onApplyFilter(partial); clear calls onClearFilters(); quick-status clicks call onQuickStatusToggle. -->
<!-- @UX_STATE default: no active badges, all tasks visible, full-filter panel collapsed. -->
<!-- @UX_STATE filtered: active badge count and clear button visible. -->
<script lang="ts">
import { Button, Icon, Input, Select } from '$lib/ui';
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
let {
filters,
onApplyFilter = (_partial: Partial<TaskCenterFilter>) => {},
onClearFilters = () => {},
quickStatusCounts = { failed: 0, in_progress: 0, success: 0 },
onQuickStatusToggle = (_status: 'failed' | 'in_progress' | 'success') => {},
}: {
filters: TaskCenterFilter;
onApplyFilter?: (_partial: Partial<TaskCenterFilter>) => void;
onClearFilters?: () => void;
quickStatusCounts?: { failed: number; in_progress: number; success: number };
onQuickStatusToggle?: (_status: 'failed' | 'in_progress' | 'success') => void;
} = $props();
const taskTypeOptions = [
{ value: '', label: 'Все типы' },
{ value: 'llm_verification', label: 'LLM' },
{ value: 'backup', label: 'Бекапы' },
{ value: 'migration', label: 'Миграции' },
{ value: 'documentation', label: 'Документация' },
{ value: 'clean_release', label: 'Clean Release' },
{ value: 'unknown', label: 'Прочие' },
];
const statusOptions = [
{ value: '', label: 'Все статусы' },
{ value: 'in_progress', label: 'В работе' },
{ value: 'success', label: 'Успешно' },
{ value: 'failed', label: 'Упало' },
{ value: 'partial', label: 'Частично' },
];
const sortOptions = [
{ value: 'updated_at', label: 'По обновлению' },
{ value: 'created_at', label: 'По созданию' },
{ value: 'status', label: 'По статусу' },
{ value: 'task_type', label: 'По типу' },
];
const timeRangeOptions = [
{ value: 'all', label: 'Всё время' },
{ value: '1h', label: 'Час' },
{ value: '24h', label: '24 часа' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
];
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let expanded: boolean = $state(false);
const selectedType = $derived(filters.task_types[0] ?? '');
const selectedStatus = $derived(filters.statuses[0] ?? '');
const activeFilterCount = $derived(
filters.task_types.length +
filters.statuses.length +
(filters.search.trim() ? 1 : 0) +
(filters.time_range !== 'all' ? 1 : 0),
);
function handleSearchInput(event: Event & { currentTarget: HTMLInputElement }): void {
clearTimeout(searchTimeout);
const search = event.currentTarget.value;
searchTimeout = setTimeout(() => onApplyFilter({ search }), 350);
}
function setTaskType(event: Event & { currentTarget: HTMLSelectElement }): void {
const value = event.currentTarget.value as TaskType | '';
onApplyFilter({ task_types: value ? [value] : [] });
}
function setStatus(event: Event & { currentTarget: HTMLSelectElement }): void {
const value = event.currentTarget.value as ReportStatus | '';
onApplyFilter({ statuses: value ? [value] : [] });
}
function setSort(event: Event & { currentTarget: HTMLSelectElement }): void {
onApplyFilter({ sort_by: event.currentTarget.value as TaskCenterFilter['sort_by'] });
}
function setTimeRange(event: Event & { currentTarget: HTMLSelectElement }): void {
onApplyFilter({ time_range: event.currentTarget.value as TaskCenterFilter['time_range'] });
}
function toggleSortOrder(): void {
onApplyFilter({ sort_order: filters.sort_order === 'asc' ? 'desc' : 'asc' });
}
function toggleExpanded(): void {
expanded = !expanded;
}
</script>
<section class="rounded-lg border border-border bg-surface-card p-3" aria-label="Фильтры задач">
<!-- Always-visible compact row: quick pills ··· search ··· full-filter disclosure -->
<div class="flex flex-wrap items-center gap-2">
<!-- Quick status pills -->
<Button
variant="ghost"
size="sm"
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive ring-1 ring-destructive-ring/40' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
aria-pressed={filters.statuses.includes('failed')}
onclick={() => onQuickStatusToggle('failed')}
>
Упавшие {quickStatusCounts.failed}
</Button>
<Button
variant="ghost"
size="sm"
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary-light text-primary ring-1 ring-primary-ring/40' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
aria-pressed={filters.statuses.includes('in_progress')}
onclick={() => onQuickStatusToggle('in_progress')}
>
В работе {quickStatusCounts.in_progress}
</Button>
<Button
variant="ghost"
size="sm"
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success ring-1 ring-success-ring/40' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
aria-pressed={filters.statuses.includes('success')}
onclick={() => onQuickStatusToggle('success')}
>
Успешные {quickStatusCounts.success}
</Button>
<!-- Spacer + search -->
<div class="flex-1 min-w-[10rem] ml-auto flex items-center gap-2">
<Input
id="reports-task-search"
name="reports-task-search"
type="search"
placeholder="Поиск по названию или ID..."
value={filters.search}
oninput={handleSearchInput}
/>
<!-- Full-filter disclosure -->
<Button
variant="ghost"
size="sm"
class="h-9 shrink-0 gap-1.5 rounded-lg border px-2.5 font-semibold {expanded ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text hover:bg-surface-muted'}"
aria-label={expanded ? 'Скрыть фильтры' : 'Показать фильтры'}
aria-expanded={expanded}
aria-controls="reports-full-filter-panel"
onclick={toggleExpanded}
>
<Icon name="filter" size={18} strokeWidth={2.2} />
<span>Фильтры</span>
{#if activeFilterCount > 0}
<span class="ml-0.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-white" aria-hidden="true">
{activeFilterCount}
</span>
{/if}
<Icon name="chevronDown" size={14} class="transition-transform {expanded ? 'rotate-180' : ''}" strokeWidth={2.2} />
</Button>
</div>
</div>
<!-- Expandable full-filter panel -->
{#if expanded}
<div id="reports-full-filter-panel" class="mt-2 flex flex-wrap items-end gap-2 pt-2 border-t border-border">
<div class="w-full sm:w-auto min-w-[10rem]">
<Select id="task-type-filter" name="task-type-filter" label="Тип" value={selectedType} options={taskTypeOptions} onchange={setTaskType} />
</div>
<div class="w-full sm:w-auto min-w-[10rem]">
<Select id="status-filter" name="status-filter" label="Статус" value={selectedStatus} options={statusOptions} onchange={setStatus} />
</div>
<div class="w-full sm:w-auto min-w-[10rem]">
<Select id="time-range-filter" name="time-range-filter" label="Период" value={filters.time_range} options={timeRangeOptions} onchange={setTimeRange} />
</div>
<div class="w-full sm:w-auto min-w-[9rem]">
<Select id="sort-filter" name="sort-filter" label="Сортировка" value={filters.sort_by} options={sortOptions} onchange={setSort} />
</div>
<Button variant="ghost" size="sm" class="border border-border" aria-label="Изменить направление сортировки" onclick={toggleSortOrder}>
{filters.sort_order === 'asc' ? '↑' : '↓'}
</Button>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" onclick={onClearFilters}>Сбросить</Button>
{/if}
</div>
{/if}
</section>
<!-- #endregion FilterBar -->

View File

@@ -0,0 +1,102 @@
/**
* @vitest-environment jsdom
*/
// #region FilterBarTest [C:3] [TYPE Module] [SEMANTICS test,reports,filter,task-status-center]
// @BRIEF UX tests for Task Status Center FilterBar with compact expandable layout.
// @RELATION BINDS_TO -> [FilterBar]
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FilterBar from '../FilterBar.svelte';
import type { TaskCenterFilter } from '$types/reports';
const baseFilters = (): TaskCenterFilter => ({
task_types: [],
statuses: [],
search: '',
time_range: 'all',
sort_by: 'updated_at',
sort_order: 'desc',
});
const baseProps = () => ({
filters: baseFilters(),
quickStatusCounts: { failed: 0, in_progress: 0, success: 0 },
});
/** Opens the full-filter panel by clicking the ⚙ toggle. */
async function expandPanel() {
await fireEvent.click(screen.getByLabelText('Показать фильтры'));
}
describe('FilterBar', () => {
beforeEach(() => vi.clearAllMocks());
it('selecting type calls onApplyFilter with task_types', async () => {
const onApplyFilter = vi.fn();
render(FilterBar, { ...baseProps(), onApplyFilter });
await expandPanel();
await fireEvent.change(screen.getByLabelText('Тип'), { target: { value: 'backup' } });
expect(onApplyFilter).toHaveBeenCalledWith({ task_types: ['backup'] });
});
it('selecting status calls onApplyFilter with statuses', async () => {
const onApplyFilter = vi.fn();
render(FilterBar, { ...baseProps(), onApplyFilter });
await expandPanel();
await fireEvent.change(screen.getByLabelText('Статус'), { target: { value: 'failed' } });
expect(onApplyFilter).toHaveBeenCalledWith({ statuses: ['failed'] });
});
it('typing search calls onApplyFilter after debounce', async () => {
vi.useFakeTimers();
const onApplyFilter = vi.fn();
render(FilterBar, { ...baseProps(), onApplyFilter });
await fireEvent.input(screen.getByPlaceholderText('Поиск по названию или ID...'), { target: { value: 'migration' } });
vi.advanceTimersByTime(351);
expect(onApplyFilter).toHaveBeenCalledWith({ search: 'migration' });
vi.useRealTimers();
});
it('clear button calls onClearFilters with active filters', async () => {
const onClearFilters = vi.fn();
render(FilterBar, {
...baseProps(),
filters: { ...baseFilters(), task_types: ['backup'], statuses: ['failed'] },
onClearFilters,
});
await expandPanel();
expect(screen.getByText('Сбросить')).toBeDefined();
await fireEvent.click(screen.getByText('Сбросить'));
expect(onClearFilters).toHaveBeenCalledOnce();
});
it('clicking quick status pill calls onQuickStatusToggle', async () => {
const onQuickStatusToggle = vi.fn();
render(FilterBar, { ...baseProps(), onQuickStatusToggle });
await fireEvent.click(screen.getByText(/Упавшие/));
expect(onQuickStatusToggle).toHaveBeenCalledWith('failed');
});
it('quick pills show correct counts', () => {
render(FilterBar, {
...baseProps(),
quickStatusCounts: { failed: 5, in_progress: 3, success: 12 },
});
expect(screen.getByText(/Упавшие 5/)).toBeDefined();
expect(screen.getByText(/В работе 3/)).toBeDefined();
expect(screen.getByText(/Успешные 12/)).toBeDefined();
});
it('toggle ⚙ shows and hides the full-filter panel', async () => {
render(FilterBar, { ...baseProps() });
// Panel is collapsed — Тип select should not be in the DOM
expect(screen.queryByLabelText('Тип')).toBeNull();
// Expand
await expandPanel();
expect(screen.getByLabelText('Тип')).toBeDefined();
// Collapse
await fireEvent.click(screen.getByLabelText('Скрыть фильтры'));
expect(screen.queryByLabelText('Тип')).toBeNull();
});
});
// #endregion FilterBarTest