Bisher blieb die Filter-Spalte auch bei geschlossenem <details>-Element in voller Breite stehen, da nur der Inhalt (nicht die Grid-Spalte) ausgeblendet wurde. Jetzt schrumpft die Spalte per :has()-Selektor auf 56px und zeigt nur noch ein Icon zum Wiederaufklappen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
401 lines
14 KiB
JavaScript
401 lines
14 KiB
JavaScript
// Filter- und Suchpanel (Phase 4, siehe anforderung.md Abschnitt
|
|
// "3. Kategorien und Filter"). Rendert alle Filter-Controls, schreibt
|
|
// Änderungen über EventMap.store.updateFilters() in den Store und
|
|
// abonniert den Store, um Controls/Trefferzahl synchron zu halten.
|
|
//
|
|
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
|
|
// innerHTML mit Nutzer-/Place-Daten (siehe js/utils/dom.js).
|
|
|
|
(function () {
|
|
const { subscribe, getState, getFilteredPlaces, updateFilters } = EventMap.store;
|
|
const { DEFAULT_FILTERS } = EventMap.filters;
|
|
const { CATEGORIES } = EventMap.config;
|
|
const { COST_TYPES, ENVIRONMENT_TYPES } = EventMap.models;
|
|
const { el, clearChildren } = EventMap.utils;
|
|
|
|
const COST_LABELS = {
|
|
free: 'Kostenlos',
|
|
paid: 'Kostenpflichtig',
|
|
mixed: 'Teilweise kostenpflichtig',
|
|
unknown: 'Keine Angabe',
|
|
};
|
|
|
|
const ENVIRONMENT_LABELS = {
|
|
indoor: 'Drinnen (Indoor)',
|
|
outdoor: 'Draußen (Outdoor)',
|
|
mixed: 'Drinnen & draußen',
|
|
};
|
|
|
|
let refs = null;
|
|
|
|
function section(legendText, children) {
|
|
return el('fieldset', { className: 'filter-panel__section' }, [
|
|
el('legend', { className: 'filter-panel__section-label', text: legendText }),
|
|
...children,
|
|
]);
|
|
}
|
|
|
|
function readCategoryFilter() {
|
|
return refs.categoryInputs.filter((input) => input.checked).map((input) => input.value);
|
|
}
|
|
|
|
function readCostTypeFilter() {
|
|
return refs.costTypeInputs.filter((input) => input.checked).map((input) => input.value);
|
|
}
|
|
|
|
function readOptionalNumber(input) {
|
|
const trimmed = input.value.trim();
|
|
if (trimmed === '') return null;
|
|
const num = Number(trimmed);
|
|
return Number.isFinite(num) ? num : null;
|
|
}
|
|
|
|
function buildCategoryGroup() {
|
|
refs.categoryInputs = [];
|
|
const options = CATEGORIES.map((cat) => {
|
|
const id = `filter-cat-${cat.id}`;
|
|
const input = el('input', {
|
|
attrs: { type: 'checkbox', id, value: cat.id },
|
|
on: { change: () => updateFilters({ categories: readCategoryFilter() }) },
|
|
});
|
|
refs.categoryInputs.push(input);
|
|
|
|
return el('label', { className: 'filter-panel__category-option', attrs: { for: id } }, [
|
|
input,
|
|
el('span', {
|
|
className: 'filter-panel__category-swatch',
|
|
attrs: { 'aria-hidden': 'true' },
|
|
style: { backgroundColor: cat.color },
|
|
}),
|
|
`${cat.icon} ${cat.label}`,
|
|
]);
|
|
});
|
|
|
|
return el('div', { className: 'filter-panel__categories' }, options);
|
|
}
|
|
|
|
function buildDistanceGroup() {
|
|
refs.maxDistance = el('input', {
|
|
attrs: { type: 'number', min: '1', step: '1', id: 'filter-max-distance', placeholder: 'z.B. 20' },
|
|
on: {
|
|
input: () => updateFilters({ maxDistanceKm: readOptionalNumber(refs.maxDistance) }),
|
|
},
|
|
});
|
|
|
|
refs.distanceHint = el('p', {
|
|
className: 'filter-panel__hint',
|
|
text: 'Bitte zuerst Standort freigeben, um nach Entfernung zu filtern.',
|
|
});
|
|
|
|
refs.useLocationBtn = el(
|
|
'button',
|
|
{
|
|
className: 'btn btn--secondary filter-panel__location-btn',
|
|
attrs: { type: 'button' },
|
|
on: { click: handleUseLocationClick },
|
|
},
|
|
['Standort verwenden']
|
|
);
|
|
|
|
return el('div', { className: 'filter-panel__distance' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-distance' } }, [
|
|
'Umkreis (km)',
|
|
]),
|
|
refs.maxDistance,
|
|
refs.distanceHint,
|
|
refs.useLocationBtn,
|
|
]);
|
|
}
|
|
|
|
function handleUseLocationClick() {
|
|
const { requestUserLocation } = EventMap.map; // Laufzeitzugriff: js/map/geolocation.js lädt vor diesem Modul, Zugriff dennoch defensiv zur Aufrufzeit.
|
|
if (!requestUserLocation) return;
|
|
|
|
refs.useLocationBtn.disabled = true;
|
|
const defaultLabel = refs.useLocationBtn.textContent;
|
|
refs.useLocationBtn.textContent = 'Standort wird ermittelt…';
|
|
|
|
requestUserLocation({
|
|
onSuccess: () => {
|
|
refs.useLocationBtn.disabled = false;
|
|
refs.useLocationBtn.textContent = defaultLabel;
|
|
},
|
|
onError: () => {
|
|
refs.useLocationBtn.disabled = false;
|
|
refs.useLocationBtn.textContent = defaultLabel;
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildAgeGroup() {
|
|
refs.minAge = el('input', {
|
|
attrs: { type: 'number', min: '0', step: '1', id: 'filter-min-age', placeholder: 'ab' },
|
|
on: { input: () => updateFilters({ minAge: readOptionalNumber(refs.minAge) }) },
|
|
});
|
|
refs.maxAge = el('input', {
|
|
attrs: { type: 'number', min: '0', step: '1', id: 'filter-max-age', placeholder: 'bis' },
|
|
on: { input: () => updateFilters({ maxAge: readOptionalNumber(refs.maxAge) }) },
|
|
});
|
|
|
|
return el('div', { className: 'filter-panel__row' }, [
|
|
el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-min-age' } }, ['Alter ab']),
|
|
refs.minAge,
|
|
]),
|
|
el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-age' } }, ['Alter bis']),
|
|
refs.maxAge,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function buildCostGroup() {
|
|
refs.costTypeInputs = [];
|
|
const options = COST_TYPES.map((type) => {
|
|
const id = `filter-cost-${type}`;
|
|
const input = el('input', {
|
|
attrs: { type: 'checkbox', id, value: type },
|
|
on: { change: () => updateFilters({ costTypes: readCostTypeFilter() }) },
|
|
});
|
|
refs.costTypeInputs.push(input);
|
|
|
|
return el('label', { className: 'filter-panel__checkbox', attrs: { for: id } }, [
|
|
input,
|
|
COST_LABELS[type] || type,
|
|
]);
|
|
});
|
|
|
|
return el('div', { className: 'filter-panel__checkbox-group' }, options);
|
|
}
|
|
|
|
function buildEnvironmentGroup() {
|
|
refs.environment = el(
|
|
'select',
|
|
{ attrs: { id: 'filter-environment' }, on: { change: () => updateFilters({ environment: refs.environment.value || null }) } },
|
|
[
|
|
el('option', { attrs: { value: '' }, text: 'Indoor & Outdoor' }),
|
|
...ENVIRONMENT_TYPES.map((type) =>
|
|
el('option', { attrs: { value: type }, text: ENVIRONMENT_LABELS[type] || type })
|
|
),
|
|
]
|
|
);
|
|
|
|
return el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-environment' } }, [
|
|
'Indoor / Outdoor',
|
|
]),
|
|
refs.environment,
|
|
]);
|
|
}
|
|
|
|
function facilityCheckbox(id, labelText, filterKey) {
|
|
const input = el('input', {
|
|
attrs: { type: 'checkbox', id },
|
|
on: { change: () => updateFilters({ [filterKey]: input.checked }) },
|
|
});
|
|
const wrapper = el('label', { className: 'filter-panel__checkbox', attrs: { for: id } }, [
|
|
input,
|
|
labelText,
|
|
]);
|
|
return { wrapper, input };
|
|
}
|
|
|
|
function buildFacilitiesGroup() {
|
|
const stroller = facilityCheckbox('filter-facility-stroller', 'Kinderwagen geeignet', 'requireStroller');
|
|
const wheelchair = facilityCheckbox('filter-facility-wheelchair', 'Barrierefrei', 'requireWheelchair');
|
|
const parking = facilityCheckbox('filter-facility-parking', 'Mit Parkplatz', 'requireParking');
|
|
const toilets = facilityCheckbox('filter-facility-toilets', 'Mit Toiletten', 'requireToilets');
|
|
const restaurant = facilityCheckbox('filter-facility-restaurant', 'Mit Gastronomie', 'requireRestaurant');
|
|
|
|
refs.requireStroller = stroller.input;
|
|
refs.requireWheelchair = wheelchair.input;
|
|
refs.requireParking = parking.input;
|
|
refs.requireToilets = toilets.input;
|
|
refs.requireRestaurant = restaurant.input;
|
|
|
|
return el('div', { className: 'filter-panel__checkbox-group' }, [
|
|
stroller.wrapper,
|
|
wheelchair.wrapper,
|
|
parking.wrapper,
|
|
toilets.wrapper,
|
|
restaurant.wrapper,
|
|
]);
|
|
}
|
|
|
|
function buildDurationGroup() {
|
|
refs.maxDuration = el('input', {
|
|
attrs: { type: 'number', min: '0', step: '15', id: 'filter-max-duration', placeholder: 'z.B. 120' },
|
|
on: { input: () => updateFilters({ maxDurationMinutes: readOptionalNumber(refs.maxDuration) }) },
|
|
});
|
|
|
|
return el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-duration' } }, [
|
|
'Max. Aufenthaltsdauer (Minuten)',
|
|
]),
|
|
refs.maxDuration,
|
|
]);
|
|
}
|
|
|
|
function buildRatingGroup() {
|
|
refs.minRating = el(
|
|
'select',
|
|
{
|
|
attrs: { id: 'filter-min-rating' },
|
|
on: {
|
|
change: () =>
|
|
updateFilters({ minRating: refs.minRating.value === '' ? null : Number(refs.minRating.value) }),
|
|
},
|
|
},
|
|
[
|
|
el('option', { attrs: { value: '' }, text: 'Alle Bewertungen' }),
|
|
el('option', { attrs: { value: '1' }, text: '★ 1+' }),
|
|
el('option', { attrs: { value: '2' }, text: '★★ 2+' }),
|
|
el('option', { attrs: { value: '3' }, text: '★★★ 3+' }),
|
|
el('option', { attrs: { value: '4' }, text: '★★★★ 4+' }),
|
|
el('option', { attrs: { value: '5' }, text: '★★★★★ 5' }),
|
|
]
|
|
);
|
|
|
|
return el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-min-rating' } }, [
|
|
'Mindestbewertung',
|
|
]),
|
|
refs.minRating,
|
|
]);
|
|
}
|
|
|
|
function buildSearchGroup() {
|
|
refs.searchText = el('input', {
|
|
attrs: { type: 'search', id: 'filter-search', placeholder: 'Name, Ort, Region, Adresse …' },
|
|
on: { input: () => updateFilters({ searchText: refs.searchText.value }) },
|
|
});
|
|
|
|
return el('div', { className: 'filter-panel__field' }, [
|
|
el('label', { className: 'filter-panel__label', attrs: { for: 'filter-search' } }, [
|
|
'Region oder Ort suchen',
|
|
]),
|
|
refs.searchText,
|
|
]);
|
|
}
|
|
|
|
function buildSkeleton(rootContainer) {
|
|
refs = {};
|
|
|
|
refs.countDisplay = el('p', { className: 'filter-panel__count' });
|
|
|
|
refs.resetBtn = el(
|
|
'button',
|
|
{
|
|
className: 'btn btn--secondary',
|
|
attrs: { type: 'button' },
|
|
on: { click: () => updateFilters({ ...DEFAULT_FILTERS }) },
|
|
},
|
|
['Filter zurücksetzen']
|
|
);
|
|
|
|
refs.zoomToResultsBtn = el(
|
|
'button',
|
|
{
|
|
className: 'btn btn--secondary',
|
|
attrs: { type: 'button' },
|
|
on: { click: () => EventMap.map.fitToPlaces(getFilteredPlaces()) },
|
|
},
|
|
['Auf Ergebnisse zoomen']
|
|
);
|
|
|
|
const body = el('div', { className: 'filter-panel__body' }, [
|
|
buildSearchGroup(),
|
|
section('Kategorie', [buildCategoryGroup()]),
|
|
section('Entfernung', [buildDistanceGroup()]),
|
|
section('Alter der Kinder', [buildAgeGroup()]),
|
|
section('Kosten', [buildCostGroup()]),
|
|
section('Umgebung', [buildEnvironmentGroup()]),
|
|
section('Ausstattung', [buildFacilitiesGroup()]),
|
|
section('Aufenthaltsdauer', [buildDurationGroup()]),
|
|
section('Bewertung', [buildRatingGroup()]),
|
|
el('div', { className: 'filter-panel__actions' }, [refs.countDisplay, refs.resetBtn, refs.zoomToResultsBtn]),
|
|
]);
|
|
|
|
const summary = el(
|
|
'summary',
|
|
{ className: 'filter-panel__summary', attrs: { 'aria-label': 'Filter & Suche ein-/ausblenden' } },
|
|
[
|
|
el('span', { className: 'filter-panel__summary-icon', attrs: { 'aria-hidden': 'true' }, text: '🔍' }),
|
|
el('span', { className: 'filter-panel__summary-text', text: 'Filter & Suche' }),
|
|
]
|
|
);
|
|
|
|
refs.details = el('details', { className: 'filter-panel__details' }, [summary, body]);
|
|
|
|
clearChildren(rootContainer);
|
|
rootContainer.appendChild(refs.details);
|
|
}
|
|
|
|
/** Setzt die Werte aller Controls anhand des aktuellen Filter-Objekts, ohne change-Events auszulösen. */
|
|
function syncControlsFromFilters(filters) {
|
|
for (const input of refs.categoryInputs) {
|
|
input.checked = filters.categories.includes(input.value);
|
|
}
|
|
for (const input of refs.costTypeInputs) {
|
|
input.checked = filters.costTypes.includes(input.value);
|
|
}
|
|
|
|
setNumberInputIfChanged(refs.maxDistance, filters.maxDistanceKm);
|
|
setNumberInputIfChanged(refs.minAge, filters.minAge);
|
|
setNumberInputIfChanged(refs.maxAge, filters.maxAge);
|
|
setNumberInputIfChanged(refs.maxDuration, filters.maxDurationMinutes);
|
|
|
|
refs.environment.value = filters.environment || '';
|
|
refs.minRating.value = filters.minRating ? String(filters.minRating) : '';
|
|
refs.requireStroller.checked = Boolean(filters.requireStroller);
|
|
refs.requireWheelchair.checked = Boolean(filters.requireWheelchair);
|
|
refs.requireParking.checked = Boolean(filters.requireParking);
|
|
refs.requireToilets.checked = Boolean(filters.requireToilets);
|
|
refs.requireRestaurant.checked = Boolean(filters.requireRestaurant);
|
|
|
|
if (refs.searchText.value !== filters.searchText) {
|
|
refs.searchText.value = filters.searchText || '';
|
|
}
|
|
}
|
|
|
|
function setNumberInputIfChanged(input, value) {
|
|
const newValue = value === null || value === undefined ? '' : String(value);
|
|
if (input.value !== newValue) {
|
|
input.value = newValue;
|
|
}
|
|
}
|
|
|
|
function syncDistanceAvailability(userLocation) {
|
|
const available = Boolean(userLocation);
|
|
refs.maxDistance.disabled = !available;
|
|
refs.distanceHint.hidden = available;
|
|
refs.useLocationBtn.hidden = available;
|
|
}
|
|
|
|
function updateCountDisplay(filteredCount, totalCount) {
|
|
refs.countDisplay.textContent = `${filteredCount} von ${totalCount} Ausflugszielen`;
|
|
}
|
|
|
|
function applyState(state) {
|
|
const filters = state.filters || DEFAULT_FILTERS;
|
|
syncControlsFromFilters(filters);
|
|
syncDistanceAvailability(state.userLocation);
|
|
updateCountDisplay(getFilteredPlaces().length, state.places.length);
|
|
}
|
|
|
|
/** Initialisiert das Filter-Panel im Element mit der übergebenen id und abonniert den Store. */
|
|
function initFilterPanel(containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) {
|
|
console.error(`EventMap: Filter-Panel-Container #${containerId} nicht gefunden.`);
|
|
return;
|
|
}
|
|
|
|
buildSkeleton(container);
|
|
applyState(getState());
|
|
subscribe(applyState);
|
|
}
|
|
|
|
EventMap.ui.initFilterPanel = initFilterPanel;
|
|
})();
|