Merge pull request 'feat(frontend): compact advanced race filters' (#48) from agent/runners-calendar:feat/compact-calendar-filters into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in New Issue
Block a user