feat(frontend): move advanced race filters below hero
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
2026-08-12 23:46:20 +03:00
parent b9b98d2164
commit 1b8235fa6e
2 changed files with 267 additions and 102 deletions

View File

@@ -145,8 +145,8 @@ export function RacesPage(): JSX.Element {
const [sortOrder, setSortOrder] = useState<RaceSort>(() => (searchParams.get("sort") as RaceSort | null) ?? "dateAsc");
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
const [isFiltersOpen, setIsFiltersOpen] = useState<boolean>(() =>
["year", "month", "status", "dateFrom", "dateTo", "distanceMin", "distanceMax", "q", "sort"].some((key) => searchParams.has(key)),
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
);
const setViewModePersist = useCallback((mode: ViewMode) => {
@@ -300,11 +300,7 @@ export function RacesPage(): JSX.Element {
]
.filter(Boolean)
.join(" ");
const activeFilterCount = [
viewMode === "list" ? yearFilter : "",
monthFilter,
statusFilter,
searchQuery,
const activeAdvancedFilterCount = [
dateFrom,
dateTo,
distanceMin,
@@ -323,7 +319,7 @@ export function RacesPage(): JSX.Element {
return (
<section className="page page--races">
<section className="races-hero" aria-label="Календарь стартов">
<section className={`races-hero${isAdvancedFiltersOpen ? " races-hero--filters-open" : ""}`} aria-label="Календарь стартов">
<div className="races-hero__content">
<p className="races-hero__eyebrow">Сезонная афиша</p>
<h1 className="page__title">Календарь стартов</h1>
@@ -345,94 +341,155 @@ export function RacesPage(): JSX.Element {
</button>
</div>
</div>
<details
className="races-hero__filters"
open={isFiltersOpen}
onToggle={(event) => setIsFiltersOpen(event.currentTarget.open)}
>
<summary className="races-hero__filters-summary">
<span>Фильтры{activeFilterCount > 0 ? ` · ${activeFilterCount} активн.` : ""}</span>
</summary>
<div className="races-filter" role="search" aria-label="Фильтр по дате">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => {
setYearFilter(event.target.value);
}}
{!isAdvancedFiltersOpen ? (
<div className="races-hero__filters">
<div className="races-filter" role="search" aria-label="Основные фильтры">
<div className="races-filter__primary">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => {
setYearFilter(event.target.value);
}}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions(races, yearFilter).map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select
className="races-filter__select"
value={monthFilter}
onChange={(event) => {
setMonthFilter(event.target.value);
}}
>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Статус</span>
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
<option value="">Все статусы</option>
<option value="planned">Планирую</option>
<option value="registered">Зарегистрирован</option>
<option value="completed">Пробежал</option>
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Название</span>
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
</label>
</div>
<button
type="button"
className="races-filter__advanced-summary"
aria-expanded={false}
onClick={() => setIsAdvancedFiltersOpen(true)}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions(races, yearFilter).map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select
className="races-filter__select"
value={monthFilter}
onChange={(event) => {
setMonthFilter(event.target.value);
}}
>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Статус</span>
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
<option value="">Все статусы</option>
<option value="planned">Планирую</option>
<option value="registered">Зарегистрирован</option>
<option value="completed">Пробежал</option>
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Поиск</span>
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата от</span>
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата до</span>
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция от, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция до, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Сортировка</span>
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSort)}>
<option value="dateAsc">Дата: сначала ранние</option>
<option value="dateDesc">Дата: сначала поздние</option>
<option value="titleAsc">Название: АЯ</option>
<option value="distanceAsc">Дистанция: по возрастанию</option>
</select>
</label>
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
</button>
</div>
</div>
<div className="races-filter__actions">
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
</div>
</details>
) : null}
</section>
{isAdvancedFiltersOpen ? (
<section className="races-filter-panel" aria-label="Расширенные фильтры стартов">
<div className="races-filter-panel__header">
<div>
<p className="races-filter-panel__eyebrow">Расширенный поиск</p>
<h2 className="races-filter-panel__title">Фильтры стартов</h2>
</div>
<button type="button" className="btn btn--secondary races-filter-panel__close" aria-expanded={true} onClick={() => setIsAdvancedFiltersOpen(false)}>
Свернуть фильтры
</button>
</div>
<div className="races-filter races-filter--panel" role="search" aria-label="Все фильтры стартов">
<div className="races-filter__panel-fields">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => setYearFilter(event.target.value)}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions(races, yearFilter).map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select className="races-filter__select" value={monthFilter} onChange={(event) => setMonthFilter(event.target.value)}>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Статус</span>
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
<option value="">Все статусы</option>
<option value="planned">Планирую</option>
<option value="registered">Зарегистрирован</option>
<option value="completed">Пробежал</option>
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Название</span>
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата от</span>
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата до</span>
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция от, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция до, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Сортировка</span>
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSort)}>
<option value="dateAsc">Дата: сначала ранние</option>
<option value="dateDesc">Дата: сначала поздние</option>
<option value="titleAsc">Название: АЯ</option>
<option value="distanceAsc">Дистанция: по возрастанию</option>
</select>
</label>
</div>
<div className="races-filter__actions">
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
</div>
</div>
</section>
) : null}
<div className="races-status" aria-live="polite">
<p
className={statusClassName}

View File

@@ -1564,22 +1564,60 @@ body {
backdrop-filter: blur(14px);
}
.races-hero__filters-summary {
.races-hero--filters-open {
grid-template-columns: 1fr;
min-height: 15rem;
}
.races-hero--filters-open .races-hero__content {
max-width: 42rem;
}
.races-hero__filters .races-filter {
display: block;
margin-top: 0;
}
.races-filter__primary,
.races-filter__advanced-fields {
display: grid;
gap: var(--space-4);
}
.races-filter__primary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.races-filter__primary .races-filter__field,
.races-filter__advanced-fields .races-filter__field,
.races-filter__panel-fields .races-filter__field {
min-width: 0;
}
.races-filter__advanced-summary {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: var(--space-5);
padding-top: var(--space-4);
padding-right: 0;
padding-bottom: 0;
padding-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.2);
gap: var(--space-3);
color: #ffffff;
font: inherit;
font-weight: 700;
cursor: pointer;
list-style: none;
text-align: left;
border-right: 0;
border-bottom: 0;
border-left: 0;
background: transparent;
}
.races-hero__filters-summary::-webkit-details-marker {
display: none;
}
.races-hero__filters-summary::after {
.races-filter__advanced-summary::after {
content: "+";
display: inline-flex;
align-items: center;
@@ -1593,17 +1631,62 @@ body {
line-height: 1;
}
.races-hero__filters[open] .races-hero__filters-summary::after {
.races-filter__advanced[open] .races-filter__advanced-summary::after {
content: "";
}
.races-hero__filters-summary:focus-visible {
.races-filter__advanced-summary:focus-visible {
outline: 2px solid var(--color-lime);
outline-offset: var(--space-1);
}
.races-hero__filters .races-filter {
.races-filter-panel {
display: grid;
gap: var(--space-5);
margin-top: var(--space-5);
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.races-filter-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.races-filter-panel__eyebrow {
margin: 0 0 var(--space-1);
color: var(--color-accent);
font-size: var(--font-size-caption);
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.races-filter-panel__title {
margin: 0;
color: var(--color-bg-deep);
font-size: var(--font-size-h2);
}
.races-filter-panel .races-filter {
display: block;
margin-top: 0;
}
.races-filter__panel-fields {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-4);
}
.races-filter-panel .races-filter__select,
.races-filter-panel .races-filter__input {
width: 100%;
}
.races-hero__filters .races-filter__label {
@@ -1620,6 +1703,10 @@ body {
background: rgba(255, 255, 255, 0.94);
}
.races-filter-panel .races-filter__label {
color: var(--color-text-muted);
}
.races-view-toggle {
border-color: rgba(255, 255, 255, 0.24);
background: rgba(7, 25, 39, 0.28);
@@ -2048,6 +2135,10 @@ body {
background-position: 62% center;
}
.races-filter__panel-fields {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.race-details-header {
flex-direction: column;
}
@@ -2080,6 +2171,19 @@ body {
border-radius: var(--radius-md);
}
.races-filter-panel {
padding: var(--space-5);
}
.races-filter-panel__header {
align-items: flex-start;
flex-direction: column;
}
.races-filter-panel__close {
width: 100%;
}
.dashboard-hero__title {
font-size: 3rem;
}
@@ -2097,6 +2201,10 @@ body {
.races-cal__year {
grid-template-columns: 1fr;
}
.races-filter__panel-fields {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {