feat(hikes): redesign /hikes filter as a quiet command bar

Replace the flat always-open filter slab with a collapsed command bar:
a result/totals summary, removable active-filter chips, and a Filter
toggle (with active-count badge). The control panel expands in-flow
below the bar (slide), pushing the card grid down instead of overlaying
it, with breathing room added between the hero map and the bar.

- Range filters are now dual-thumb sliders (lower + upper bound) for
  distance, duration, ascent and descent, via a new RangeSlider
  component (pointer + keyboard, crossover hand-off). Shared bounds math
  lives in filterBounds.ts so slider extents and the page's default
  filter state can't drift.
- Difficulty selection renders the actual SAC trail-sign markers
  (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-white),
  matching the hike cards; selected signs light up in full colour while
  unselected ones are dimmed.
- Tag selection uses a typeahead (input + dropdown + removable chips),
  mirroring the recipe filter but themed with the semantic variables.
This commit is contained in:
2026-05-22 12:10:18 +02:00
parent 7bede8cd64
commit 896e42f5d9
6 changed files with 1110 additions and 225 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.77.2", "version": "1.78.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+510 -131
View File
@@ -1,11 +1,27 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import X from '@lucide/svelte/icons/x';
import RangeSlider from './RangeSlider.svelte';
import TagTypeahead from './TagTypeahead.svelte';
import {
hikeFilterBounds,
DISTANCE_STEP,
DURATION_STEP,
ELEVATION_STEP
} from '$lib/hikes/filterBounds';
import type { Difficulty, HikeManifestEntry } from '$types/hikes'; import type { Difficulty, HikeManifestEntry } from '$types/hikes';
export type HikesFilter = { export type HikesFilter = {
minDistanceKm: number;
maxDistanceKm: number; maxDistanceKm: number;
minDurationMin: number;
maxDurationMin: number; maxDurationMin: number;
minGainM: number;
maxGainM: number; maxGainM: number;
minLossM: number;
maxLossM: number; maxLossM: number;
difficulties: SvelteSet<Difficulty>; difficulties: SvelteSet<Difficulty>;
regions: SvelteSet<string>; regions: SvelteSet<string>;
@@ -15,16 +31,28 @@
interface Props { interface Props {
hikes: HikeManifestEntry[]; hikes: HikeManifestEntry[];
filter: HikesFilter; filter: HikesFilter;
/** Hikes passing the current filter — shown in the bar summary. */
resultCount: number;
/** Total hikes before filtering. */
totalCount: number;
/** Summed distance / ascent over the filtered subset (already rounded). */
totalKm: number;
totalGain: number;
} }
const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']; const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
const { hikes, filter }: Props = $props(); const { hikes, filter, resultCount, totalCount, totalKm, totalGain }: Props = $props();
const maxDistance = $derived(Math.max(1, ...hikes.map((h) => Math.ceil(h.distanceKm)))); // Collapsed-by-default: the bar is just a summary + active-filter chips +
const maxDuration = $derived(Math.max(60, ...hikes.map((h) => h.durationMin ?? 0))); // a trigger until the user opens the control panel. Keeps the listing's
const maxGain = $derived(Math.max(100, ...hikes.map((h) => h.elevationGainM))); // vertical rhythm clean — the filters only take space when wanted.
const maxLoss = $derived(Math.max(100, ...hikes.map((h) => h.elevationLossM))); let open = $state(false);
let root = $state<HTMLElement>();
// Range-slider track extents, derived from the data (see filterBounds.ts —
// the same helper seeds the page's default filter state).
const bounds = $derived(hikeFilterBounds(hikes));
const regions = $derived.by(() => { const regions = $derived.by(() => {
const seen: Record<string, true> = {}; const seen: Record<string, true> = {};
@@ -54,6 +82,90 @@
.map(([t]) => t); .map(([t]) => t);
}); });
function fmtDuration(min: number) {
return `${Math.floor(min / 60)}h ${min % 60}m`;
}
// Compact chip label for a narrowed range: "≤ hi" / "≥ lo" / "lohi",
// suppressing whichever end still sits at its data bound.
function rangeLabel(
lo: number,
hi: number,
b: { min: number; max: number },
fmt: (v: number) => string,
unit: string
) {
const u = unit ? ` ${unit}` : '';
const loNarrowed = lo > b.min;
const hiNarrowed = hi < b.max;
if (loNarrowed && hiNarrowed) return `${fmt(lo)}${fmt(hi)}${u}`;
if (hiNarrowed) return `≤ ${fmt(hi)}${u}`;
return `≥ ${fmt(lo)}${u}`;
}
// SAC trail-sign colour band — matches the card badges (T1 yellow
// Wegweiser, T2/T3 red-white Bergweg, T4T6 blue-white Alpinweg). Used
// for the small colour dot on each difficulty toggle.
function sacBand(d: Difficulty): 'yellow' | 'red' | 'blue' {
if (d === 'T1') return 'yellow';
if (d === 'T2' || d === 'T3') return 'red';
return 'blue';
}
// Active filters, flattened into removable chips for the collapsed bar.
// A range counts as "active" only when narrowed below its data ceiling.
type Chip = { key: string; label: string; clear: () => void };
const chips = $derived.by<Chip[]>(() => {
const out: Chip[] = [];
const { distance, duration, gain, loss } = bounds;
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
out.push({
key: 'dist',
label: rangeLabel(filter.minDistanceKm, filter.maxDistanceKm, distance, (v) => `${v}`, 'km'),
clear: () => {
filter.minDistanceKm = distance.min;
filter.maxDistanceKm = distance.max;
}
});
if (filter.minDurationMin > duration.min || filter.maxDurationMin < duration.max)
out.push({
key: 'dur',
label: rangeLabel(filter.minDurationMin, filter.maxDurationMin, duration, fmtDuration, ''),
clear: () => {
filter.minDurationMin = duration.min;
filter.maxDurationMin = duration.max;
}
});
if (filter.minGainM > gain.min || filter.maxGainM < gain.max)
out.push({
key: 'gain',
label: `↑ ${rangeLabel(filter.minGainM, filter.maxGainM, gain, (v) => `${v}`, 'm')}`,
clear: () => {
filter.minGainM = gain.min;
filter.maxGainM = gain.max;
}
});
if (filter.minLossM > loss.min || filter.maxLossM < loss.max)
out.push({
key: 'loss',
label: `↓ ${rangeLabel(filter.minLossM, filter.maxLossM, loss, (v) => `${v}`, 'm')}`,
clear: () => {
filter.minLossM = loss.min;
filter.maxLossM = loss.max;
}
});
for (const d of DIFFICULTIES)
if (filter.difficulties.has(d))
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
for (const r of filter.regions)
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
for (const t of filter.tags)
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
return out;
});
const activeCount = $derived(chips.length);
function toggleDifficulty(d: Difficulty) { function toggleDifficulty(d: Difficulty) {
if (filter.difficulties.has(d)) filter.difficulties.delete(d); if (filter.difficulties.has(d)) filter.difficulties.delete(d);
else filter.difficulties.add(d); else filter.difficulties.add(d);
@@ -70,86 +182,132 @@
} }
function resetFilters() { function resetFilters() {
filter.maxDistanceKm = maxDistance; filter.minDistanceKm = bounds.distance.min;
filter.maxDurationMin = maxDuration; filter.maxDistanceKm = bounds.distance.max;
filter.maxGainM = maxGain; filter.minDurationMin = bounds.duration.min;
filter.maxLossM = maxLoss; filter.maxDurationMin = bounds.duration.max;
filter.minGainM = bounds.gain.min;
filter.maxGainM = bounds.gain.max;
filter.minLossM = bounds.loss.min;
filter.maxLossM = bounds.loss.max;
filter.difficulties.clear(); filter.difficulties.clear();
filter.regions.clear(); filter.regions.clear();
filter.tags.clear(); filter.tags.clear();
} }
// Light-dismiss: close the panel on outside click or Escape. Only wired
// up while open so the listeners aren't carried for the whole session.
$effect(() => {
if (!open) return;
const onPointer = (e: PointerEvent) => {
if (root && !root.contains(e.target as Node)) open = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') open = false;
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
};
});
</script> </script>
<aside class="filter-bar"> <div class="filter-bar" bind:this={root}>
<div class="row"> <p class="summary">
<label> <span class="count"><strong>{resultCount}</strong> von {totalCount} Touren</span>
<span class="label-row"> {#if resultCount > 0}
<span>Distanz</span> <span class="dot" aria-hidden="true">·</span>
<span class="value">{filter.maxDistanceKm} km</span> <span class="stat">{totalKm.toLocaleString('de-CH')} km</span>
</span> <span class="dot" aria-hidden="true">·</span>
<input <span class="stat">{totalGain.toLocaleString('de-CH')} hm</span>
type="range" {/if}
min="1" </p>
max={maxDistance}
step="1"
bind:value={filter.maxDistanceKm}
/>
</label>
<label> {#if activeCount > 0}
<span class="label-row"> <div class="active-chips" aria-label="Aktive Filter">
<span>Dauer</span> {#each chips as chip (chip.key)}
<span class="value">{Math.floor(filter.maxDurationMin / 60)}h {filter.maxDurationMin % 60}m</span> <button type="button" class="chip" onclick={chip.clear}>
</span> <span class="chip-label">{chip.label}</span>
<input <X size={13} strokeWidth={2} aria-label="entfernen" />
type="range" </button>
min="0" {/each}
max={maxDuration} <button type="button" class="clear-all" onclick={resetFilters}>Alle löschen</button>
step="15" </div>
bind:value={filter.maxDurationMin} {/if}
/>
</label>
<label> <button
<span class="label-row"> type="button"
<span>Aufstieg</span> class="filter-toggle"
<span class="value">{filter.maxGainM} m</span> class:open
</span> aria-expanded={open}
<input aria-controls="filter-panel"
type="range" onclick={() => (open = !open)}
min="0" >
max={maxGain} <SlidersHorizontal size={16} strokeWidth={1.75} aria-hidden="true" />
step="50" <span>Filter</span>
bind:value={filter.maxGainM} {#if activeCount > 0}<span class="badge">{activeCount}</span>{/if}
/> <ChevronDown class="chev" size={16} strokeWidth={1.75} aria-hidden="true" />
</label> </button>
<label> {#if open}
<span class="label-row"> <div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
<span>Abstieg</span> <div class="ranges">
<span class="value">{filter.maxLossM} m</span> <RangeSlider
</span> label="Distanz"
<input min={bounds.distance.min}
type="range" max={bounds.distance.max}
min="0" step={DISTANCE_STEP}
max={maxLoss} bind:low={filter.minDistanceKm}
step="50" bind:high={filter.maxDistanceKm}
bind:value={filter.maxLossM} format={(v) => `${v} km`}
/>
<RangeSlider
label="Dauer"
min={bounds.duration.min}
max={bounds.duration.max}
step={DURATION_STEP}
bind:low={filter.minDurationMin}
bind:high={filter.maxDurationMin}
format={fmtDuration}
/>
<RangeSlider
label="Aufstieg"
min={bounds.gain.min}
max={bounds.gain.max}
step={ELEVATION_STEP}
bind:low={filter.minGainM}
bind:high={filter.maxGainM}
format={(v) => `${v} m`}
/>
<RangeSlider
label="Abstieg"
min={bounds.loss.min}
max={bounds.loss.max}
step={ELEVATION_STEP}
bind:low={filter.minLossM}
bind:high={filter.maxLossM}
format={(v) => `${v} m`}
/> />
</label>
</div> </div>
<div class="row"> <hr class="divider" />
<fieldset> <fieldset>
<legend>Schwierigkeit (SAC)</legend> <legend>Schwierigkeit (SAC)</legend>
<div class="pills"> <div class="sac-grid">
{#each DIFFICULTIES as d (d)} {#each DIFFICULTIES as d (d)}
<button <button
type="button" type="button"
class="pill" class="sac-toggle"
class:active={filter.difficulties.has(d)} class:active={filter.difficulties.has(d)}
aria-pressed={filter.difficulties.has(d)}
aria-label="SAC-Schwierigkeit {d}"
onclick={() => toggleDifficulty(d)} onclick={() => toggleDifficulty(d)}
>{d}</button> >
<span class="sac-marker sac-marker-{sacBand(d)}">{d}</span>
</button>
{/each} {/each}
</div> </div>
</fieldset> </fieldset>
@@ -170,69 +328,198 @@
</fieldset> </fieldset>
{/if} {/if}
<button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button>
</div>
{#if tags.length > 0} {#if tags.length > 0}
<fieldset class="tags-fieldset"> <fieldset>
<legend>Schlagwörter</legend> <legend>Schlagwörter</legend>
<div class="pills"> <TagTypeahead {tags} selected={filter.tags} onToggle={toggleTag} />
{#each tags as t (t)}
<button
type="button"
class="pill pill-tag"
class:active={filter.tags.has(t)}
onclick={() => toggleTag(t)}
>
<span class="pill-tag-hash" aria-hidden="true">#</span>{t}
</button>
{/each}
</div>
</fieldset> </fieldset>
{/if} {/if}
</aside>
<div class="panel-foot">
<button type="button" class="reset" onclick={resetFilters} disabled={activeCount === 0}>
Zurücksetzen
</button>
</div>
</div>
{/if}
</div>
<style> <style>
.filter-bar { .filter-bar {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1rem 1.25rem; padding: 0.5rem 0.6rem 0.5rem 1rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.row { .summary {
display: grid; display: inline-flex;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: baseline;
gap: 1rem; flex-wrap: wrap;
} gap: 0.4rem;
margin: 0;
.row + .row { flex: 0 1 auto;
margin-top: 1rem; font-size: 0.9rem;
align-items: end;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.label-row { .count strong {
display: flex; color: var(--color-text-primary);
justify-content: space-between; font-weight: 700;
gap: 0.5rem;
} }
.value { .stat {
color: var(--color-text-primary); font-size: 0.82rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
input[type='range'] { .dot {
color: var(--color-text-muted);
}
/* Active filters surfaced inline so the user always sees what's narrowing
* the listing without opening the panel; each chip removes its own facet. */
.active-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
appearance: none;
font: inherit;
font-size: 0.78rem;
padding: 0.18rem 0.5rem 0.18rem 0.65rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 32%, var(--color-border));
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.chip:hover {
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
}
.chip :global(svg) {
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.chip:hover :global(svg) {
opacity: 1;
}
.chip-label {
font-variant-numeric: tabular-nums;
}
.clear-all {
appearance: none;
background: transparent;
border: 0;
font: inherit;
font-size: 0.78rem;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 0.18rem 0.3rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.clear-all:hover {
color: var(--color-text-primary);
}
.filter-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
appearance: none;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
padding: 0.45rem 0.8rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.filter-toggle:hover {
background: var(--color-bg-elevated);
}
.filter-toggle.open {
border-color: var(--color-primary);
background: var(--color-bg-elevated);
}
.filter-toggle :global(.chev) {
transition: rotate var(--transition-normal);
}
.filter-toggle.open :global(.chev) {
rotate: 180deg;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
padding: 0 0.35rem;
background: var(--color-primary);
color: var(--color-text-on-primary);
border-radius: var(--radius-pill);
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* Expands in-flow on its own full-width row inside the bar, so opening it
* pushes the card grid down (accordion) rather than overlaying it. The
* top border separates it from the summary row; both it and the vertical
* padding are animated by the `slide` transition. */
.panel {
width: 100%; width: 100%;
accent-color: var(--color-primary); display: flex;
flex-direction: column;
gap: 1.1rem;
margin-top: 0.6rem;
padding-top: 1.1rem;
border-top: 1px solid var(--color-border);
}
.ranges {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.1rem 1.75rem;
}
.divider {
height: 1px;
border: 0;
margin: 0;
background: var(--color-border);
} }
fieldset { fieldset {
@@ -243,15 +530,17 @@
legend { legend {
display: block; display: block;
margin-bottom: 0.4rem; margin-bottom: 0.5rem;
font-size: 0.8rem; font-size: 0.72rem;
color: var(--color-text-secondary); text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
} }
.pills { .pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.3rem; gap: 0.35rem;
} }
.pill { .pill {
@@ -264,7 +553,8 @@
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
cursor: pointer; cursor: pointer;
transition: scale var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast); transition: scale var(--transition-fast), background-color var(--transition-fast),
color var(--transition-fast);
} }
.pill:hover { .pill:hover {
@@ -278,9 +568,103 @@
border-color: var(--color-primary); border-color: var(--color-primary);
} }
/* Difficulty toggles render the actual SAC trail-sign markers (same shapes
* as the hike cards): T1 yellow Wegweiser arrow, T2/T3 white-red-white
* Bergweg, T4T6 white-blue-white Alpinweg. No container chrome — boxing
* the irregular arrow looked off. Selection is the sign itself "lighting
* up": unselected signs are dimmed + desaturated, the selected ones snap
* to full colour, scale up and lift with a shadow. */
.sac-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.sac-toggle {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: none;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.45;
filter: grayscale(0.6);
transition: scale var(--transition-fast), opacity var(--transition-fast),
filter var(--transition-fast);
}
.sac-toggle:hover {
opacity: 0.85;
filter: grayscale(0.1);
scale: 1.08;
}
.sac-toggle.active {
opacity: 1;
filter: none;
scale: 1.08;
}
.sac-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Lift only the selected signs so they read as raised above the dimmed
* ones. (Applied to the marker, not the toggle, so it survives the
* toggle's `filter: none`.) */
.sac-toggle.active .sac-marker {
filter: drop-shadow(0 2px 5px rgb(0 0 0 / 0.35));
}
.sac-marker {
display: inline-flex;
align-items: center;
justify-content: center;
height: 26px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.sac-marker-yellow {
width: 44px;
color: #1a1a1a;
background: #f5a623;
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
/* Text sits in the rectangular left portion (arrow tip is the right 25%). */
justify-content: flex-start;
padding-left: 0.55rem;
}
.sac-marker-red,
.sac-marker-blue {
width: 32px;
color: #fff;
text-shadow: 0 1px 1px rgb(0 0 0 / 0.45);
border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
}
.sac-marker-red {
background: linear-gradient(to bottom, #fff 0 25%, #dc1d2a 25% 75%, #fff 75% 100%);
}
.sac-marker-blue {
background: linear-gradient(to bottom, #fff 0 25%, #2965c8 25% 75%, #fff 75% 100%);
}
.panel-foot {
display: flex;
justify-content: flex-end;
}
.reset { .reset {
align-self: center;
justify-self: end;
appearance: none; appearance: none;
background: transparent; background: transparent;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -290,31 +674,26 @@
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-fast);
} }
.reset:hover { .reset:hover:not(:disabled) {
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
} }
/* Tags fieldset spans the full filter-bar width — there can be many .reset:disabled {
* (every word an author uses), and forcing it into a column with the opacity: 0.45;
* single-line region/difficulty groups makes them all unreadable. */ cursor: default;
.tags-fieldset {
margin-top: 1rem;
} }
.pill-tag { @media (max-width: 560px) {
display: inline-flex; .ranges {
align-items: baseline; grid-template-columns: 1fr;
gap: 0.2rem;
} }
.pill-tag-hash { /* Give the trigger its own line so the summary + chips aren't squeezed. */
opacity: 0.55; .summary {
font-weight: 600; flex: 1 1 100%;
} }
.pill-tag.active .pill-tag-hash {
opacity: 0.85;
} }
</style> </style>
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
// Dual-thumb range slider: one track, two handles (lower + upper bound).
// Custom pointer/keyboard implementation rather than two overlaid
// <input type=range> elements — the latter lock up when both thumbs
// coincide at an edge. Here a drag that crosses the other thumb hands off
// to it, so the range is always adjustable.
interface Props {
label: string;
/** Track extent (data floor / ceiling). */
min: number;
max: number;
step?: number;
/** Current lower bound. Bindable. */
low: number;
/** Current upper bound. Bindable. */
high: number;
/** Renders a value for the readout + aria-valuetext. */
format?: (v: number) => string;
}
let {
label,
min,
max,
step = 1,
low = $bindable(),
high = $bindable(),
format = (v) => String(v)
}: Props = $props();
let trackEl = $state<HTMLElement>();
let lowThumb = $state<HTMLElement>();
let highThumb = $state<HTMLElement>();
let dragging = $state<null | 'low' | 'high'>(null);
const span = $derived(Math.max(1, max - min));
// Clamp for display so an out-of-range initial value (e.g. ±Infinity
// before the data defaults land) still paints a sane thumb position.
const lowPct = $derived(((Math.min(Math.max(low, min), max) - min) / span) * 100);
const highPct = $derived(((Math.min(Math.max(high, min), max) - min) / span) * 100);
function snap(v: number) {
return Math.round(v / step) * step;
}
function setLow(v: number) {
low = Math.min(Math.max(snap(v), min), high);
}
function setHigh(v: number) {
high = Math.max(Math.min(snap(v), max), low);
}
function valueFromClientX(clientX: number) {
if (!trackEl) return min;
const r = trackEl.getBoundingClientRect();
const ratio = r.width > 0 ? (clientX - r.left) / r.width : 0;
return min + Math.min(Math.max(ratio, 0), 1) * (max - min);
}
// Move the active thumb; if it crosses the other one, hand the drag over so
// dragging stays continuous instead of stalling at the collision point.
function update(which: 'low' | 'high', raw: number) {
const v = Math.min(Math.max(snap(raw), min), max);
if (which === 'low') {
if (v > high) {
dragging = 'high';
highThumb?.focus();
setHigh(v);
} else setLow(v);
} else {
if (v < low) {
dragging = 'low';
lowThumb?.focus();
setLow(v);
} else setHigh(v);
}
}
function onTrackPointerDown(e: PointerEvent) {
e.preventDefault();
const v = valueFromClientX(e.clientX);
const which: 'low' | 'high' = Math.abs(v - low) <= Math.abs(v - high) ? 'low' : 'high';
dragging = which;
(which === 'low' ? lowThumb : highThumb)?.focus();
trackEl?.setPointerCapture(e.pointerId);
update(which, v);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
update(dragging, valueFromClientX(e.clientX));
}
function onPointerUp() {
dragging = null;
}
function onThumbKey(e: KeyboardEvent, which: 'low' | 'high') {
const big = step * 10;
let delta = 0;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
delta = step;
break;
case 'ArrowLeft':
case 'ArrowDown':
delta = -step;
break;
case 'PageUp':
delta = big;
break;
case 'PageDown':
delta = -big;
break;
case 'Home':
e.preventDefault();
if (which === 'low') setLow(min);
else setHigh(low);
return;
case 'End':
e.preventDefault();
if (which === 'low') setLow(high);
else setHigh(max);
return;
default:
return;
}
e.preventDefault();
if (which === 'low') setLow(low + delta);
else setHigh(high + delta);
}
</script>
<div class="rs">
<div class="rs-head">
<span class="rs-label">{label}</span>
<span class="rs-value">{format(low)} {format(high)}</span>
</div>
<div
class="rs-track"
role="group"
aria-label={label}
bind:this={trackEl}
onpointerdown={onTrackPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="rs-rail"></div>
<div class="rs-fill" style="left: {lowPct}%; right: {100 - highPct}%;"></div>
<button
type="button"
class="rs-thumb"
class:active={dragging === 'low'}
bind:this={lowThumb}
style="left: {lowPct}%"
role="slider"
tabindex="0"
aria-label="{label} Minimum"
aria-valuemin={min}
aria-valuemax={high}
aria-valuenow={low}
aria-valuetext={format(low)}
onkeydown={(e) => onThumbKey(e, 'low')}
></button>
<button
type="button"
class="rs-thumb"
class:active={dragging === 'high'}
bind:this={highThumb}
style="left: {highPct}%"
role="slider"
tabindex="0"
aria-label="{label} Maximum"
aria-valuemin={low}
aria-valuemax={max}
aria-valuenow={high}
aria-valuetext={format(high)}
onkeydown={(e) => onThumbKey(e, 'high')}
></button>
</div>
</div>
<style>
.rs {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rs-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.rs-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.rs-value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.rs-track {
position: relative;
height: 1.25rem;
touch-action: none;
cursor: pointer;
}
.rs-rail,
.rs-fill {
position: absolute;
top: 50%;
height: 0.3rem;
transform: translateY(-50%);
border-radius: var(--radius-pill);
}
.rs-rail {
left: 0;
right: 0;
background: var(--color-bg-elevated);
}
.rs-fill {
background: var(--color-primary);
}
.rs-thumb {
position: absolute;
top: 50%;
width: 1.05rem;
height: 1.05rem;
margin: 0;
padding: 0;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--color-surface);
border: 2px solid var(--color-primary);
box-shadow: var(--shadow-sm);
cursor: grab;
appearance: none;
transition: scale var(--transition-fast);
}
.rs-thumb:hover {
scale: 1.1;
}
.rs-thumb:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.rs-thumb.active {
cursor: grabbing;
scale: 1.15;
}
</style>
@@ -0,0 +1,223 @@
<script lang="ts">
// Tag selection by typeahead — same interaction as the recipe TagFilter:
// a text field that opens a dropdown of matching tags, with the picked
// tags shown below as removable chips. Themed with the semantic variables
// (the recipe original hardcodes Nord values) so it fits the filter panel
// in both colour schemes.
import type { SvelteSet } from 'svelte/reactivity';
import X from '@lucide/svelte/icons/x';
interface Props {
/** All selectable tags, in display order (frequency-sorted upstream). */
tags: string[];
/** Currently-selected tags. Mutated via {@link onToggle}. */
selected: SvelteSet<string>;
onToggle: (tag: string) => void;
}
const { tags, selected, onToggle }: Props = $props();
let inputValue = $state('');
let open = $state(false);
let wrapper = $state<HTMLElement>();
let inputEl = $state<HTMLInputElement>();
const unselected = $derived(tags.filter((t) => !selected.has(t)));
const filtered = $derived(
inputValue.trim() === ''
? unselected
: unselected.filter((t) => t.toLowerCase().includes(inputValue.trim().toLowerCase()))
);
// Selected tags kept in the canonical display order rather than click order.
const selectedList = $derived(tags.filter((t) => selected.has(t)));
function pick(tag: string) {
onToggle(tag);
inputValue = '';
// Keep the field focused so several tags can be added in a row.
inputEl?.focus();
open = true;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const value = inputValue.trim().toLowerCase();
const match = filtered.find((t) => t.toLowerCase() === value) ?? filtered[0];
if (match) pick(match);
} else if (e.key === 'Escape') {
if (inputValue) {
inputValue = '';
} else {
open = false;
inputEl?.blur();
}
}
}
// Close when focus leaves the whole widget (click-away / tab-out), but stay
// open while moving between the input and its dropdown chips.
function onFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (!next || !wrapper?.contains(next)) open = false;
}
</script>
<div class="tt" bind:this={wrapper} onfocusout={onFocusOut}>
<div class="tt-field">
<input
class="tt-input"
type="text"
bind:this={inputEl}
bind:value={inputValue}
onfocus={() => (open = true)}
onkeydown={onKey}
placeholder="Schlagwort eingeben oder auswählen…"
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-controls="tt-dropdown"
/>
{#if open && filtered.length > 0}
<div class="tt-dropdown" id="tt-dropdown">
{#each filtered as tag (tag)}
<button type="button" class="tt-option" onclick={() => pick(tag)}>
<span class="tt-hash" aria-hidden="true">#</span>{tag}
</button>
{/each}
</div>
{/if}
</div>
{#if selectedList.length > 0}
<div class="tt-selected">
{#each selectedList as tag (tag)}
<button
type="button"
class="tt-chip"
onclick={() => onToggle(tag)}
aria-label="{tag} entfernen"
>
<span class="tt-hash" aria-hidden="true">#</span>{tag}
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
{/each}
</div>
{/if}
</div>
<style>
.tt {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tt-field {
position: relative;
}
.tt-input {
box-sizing: border-box;
width: 100%;
font: inherit;
font-size: 0.85rem;
padding: 0.5rem 0.7rem;
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
.tt-input::placeholder {
color: var(--color-text-tertiary);
}
.tt-input:focus-visible {
outline: none;
border-color: var(--color-primary);
}
.tt-dropdown {
position: absolute;
top: calc(100% + 0.3rem);
left: 0;
right: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.tt-option {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
transition: scale var(--transition-fast), background-color var(--transition-fast),
color var(--transition-fast);
}
.tt-option:hover {
scale: 1.05;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.tt-selected {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tt-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.5rem 0.25rem 0.7rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-on-primary);
background: var(--color-primary);
border: 1px solid var(--color-primary);
transition: scale var(--transition-fast), background-color var(--transition-fast);
}
.tt-chip:hover {
scale: 1.05;
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.tt-chip :global(svg) {
opacity: 0.85;
}
.tt-hash {
opacity: 0.6;
font-weight: 600;
}
.tt-chip .tt-hash {
opacity: 0.85;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
import type { HikeManifestEntry } from '$types/hikes';
// Shared min/max derivation for the hikes range filters, so the slider track
// extents (HikesFilterBar) and the page's default filter state (+page.svelte)
// always agree — otherwise a thumb could sit off-track or a phantom "active"
// chip could appear on first paint.
export type Bounds = { min: number; max: number };
export const DISTANCE_STEP = 1;
export const DURATION_STEP = 15;
export const ELEVATION_STEP = 50;
/** Data floor/ceiling for a metric, snapped outward to whole `step` units so
* the slider ends land on clean values. Always returns min < max. */
export function alignBounds(values: number[], step: number): Bounds {
if (values.length === 0) return { min: 0, max: step };
const lo = Math.min(...values);
const hi = Math.max(...values);
const min = Math.floor(lo / step) * step;
let max = Math.ceil(hi / step) * step;
if (max <= min) max = min + step;
return { min, max };
}
export type HikeFilterBounds = {
distance: Bounds;
duration: Bounds;
gain: Bounds;
loss: Bounds;
};
export function hikeFilterBounds(hikes: HikeManifestEntry[]): HikeFilterBounds {
return {
distance: alignBounds(
hikes.map((h) => h.distanceKm),
DISTANCE_STEP
),
duration: alignBounds(
hikes.map((h) => h.durationMin ?? 0),
DURATION_STEP
),
gain: alignBounds(
hikes.map((h) => h.elevationGainM),
ELEVATION_STEP
),
loss: alignBounds(
hikes.map((h) => h.elevationLossM),
ELEVATION_STEP
)
};
}
+30 -69
View File
@@ -7,6 +7,7 @@
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte'; import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
import Seo from '$lib/components/Seo.svelte'; import Seo from '$lib/components/Seo.svelte';
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated'; import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
import type { Difficulty } from '$types/hikes'; import type { Difficulty } from '$types/hikes';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -51,9 +52,13 @@
// subset, which then locked the filter to that one hike until the // subset, which then locked the filter to that one hike until the
// next navigation cycle). // next navigation cycle).
const filter = $state<HikesFilter>({ const filter = $state<HikesFilter>({
minDistanceKm: Number.NEGATIVE_INFINITY,
maxDistanceKm: Number.POSITIVE_INFINITY, maxDistanceKm: Number.POSITIVE_INFINITY,
minDurationMin: Number.NEGATIVE_INFINITY,
maxDurationMin: Number.POSITIVE_INFINITY, maxDurationMin: Number.POSITIVE_INFINITY,
minGainM: Number.NEGATIVE_INFINITY,
maxGainM: Number.POSITIVE_INFINITY, maxGainM: Number.POSITIVE_INFINITY,
minLossM: Number.NEGATIVE_INFINITY,
maxLossM: Number.POSITIVE_INFINITY, maxLossM: Number.POSITIVE_INFINITY,
difficulties: new SvelteSet<Difficulty>(), difficulties: new SvelteSet<Difficulty>(),
regions: new SvelteSet<string>(), regions: new SvelteSet<string>(),
@@ -102,20 +107,26 @@
$effect(() => { $effect(() => {
if (filterDefaultsApplied) return; if (filterDefaultsApplied) return;
if (data.hikes.length === 0) return; if (data.hikes.length === 0) return;
filter.maxDistanceKm = Math.max(1, ...data.hikes.map((h) => Math.ceil(h.distanceKm))); const b = hikeFilterBounds(data.hikes);
filter.maxDurationMin = Math.max(60, ...data.hikes.map((h) => h.durationMin ?? 0)); filter.minDistanceKm = b.distance.min;
filter.maxGainM = Math.max(100, ...data.hikes.map((h) => h.elevationGainM)); filter.maxDistanceKm = b.distance.max;
filter.maxLossM = Math.max(100, ...data.hikes.map((h) => h.elevationLossM)); filter.minDurationMin = b.duration.min;
filter.maxDurationMin = b.duration.max;
filter.minGainM = b.gain.min;
filter.maxGainM = b.gain.max;
filter.minLossM = b.loss.min;
filter.maxLossM = b.loss.max;
filterDefaultsApplied = true; filterDefaultsApplied = true;
}); });
const visible = $derived.by(() => { const visible = $derived.by(() => {
const out = []; const out = [];
for (const h of data.hikes) { for (const h of data.hikes) {
if (h.distanceKm > filter.maxDistanceKm) continue; if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
if ((h.durationMin ?? 0) > filter.maxDurationMin) continue; const dur = h.durationMin ?? 0;
if (h.elevationGainM > filter.maxGainM) continue; if (dur < filter.minDurationMin || dur > filter.maxDurationMin) continue;
if (h.elevationLossM > filter.maxLossM) continue; if (h.elevationGainM < filter.minGainM || h.elevationGainM > filter.maxGainM) continue;
if (h.elevationLossM < filter.minLossM || h.elevationLossM > filter.maxLossM) continue;
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue; if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue;
if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue; if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue;
// Multi-tag = OR (a hike matching ANY selected tag is shown). AND // Multi-tag = OR (a hike matching ANY selected tag is shown). AND
@@ -198,19 +209,14 @@
</section> </section>
<div class="below-hero"> <div class="below-hero">
<header class="page-header"> <HikesFilterBar
<p class="subtitle"> hikes={data.hikes}
<strong>{visible.length}</strong> von {data.hikes.length} Touren {filter}
</p> resultCount={visible.length}
{#if visible.length > 0} totalCount={data.hikes.length}
<dl class="totals" aria-label="Gesamtsumme der gefilterten Touren"> totalKm={totals.km}
<div><dt>Distanz</dt><dd>{totals.km} km</dd></div> totalGain={totals.gain}
<div><dt>Aufstieg</dt><dd>{totals.gain.toLocaleString('de-CH')} m</dd></div> />
</dl>
{/if}
</header>
<HikesFilterBar hikes={data.hikes} {filter} />
{#if visible.length === 0} {#if visible.length === 0}
<p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p> <p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p>
@@ -305,54 +311,9 @@
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem); top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
} }
.page-header { /* Breathing room between the full-bleed hero map and the filter bar. */
display: flex; .below-hero {
flex-wrap: wrap; margin-top: 2rem;
gap: 0.5rem 1.5rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.subtitle {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.subtitle strong {
color: var(--color-text-primary);
font-weight: 700;
}
.totals {
display: flex;
gap: 1.25rem;
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.totals div {
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.totals dt {
margin: 0;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.totals dd {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
} }
.grid { .grid {