Add sticky filter form below map on desktop

- Create property-filters-sticky.php with compact filter layout
- Add StickyFilters JS module with IntersectionObserver
- Show sticky filter with 200ms fade when main filter scrolls out of view
- Hide sticky filter instantly when main filter scrolls back into view
- Bidirectional sync between main and sticky filter forms
- Changes in either form trigger the same filterProperties() call
- Desktop map view only (>= 1024px)
This commit is contained in:
Hanson.xyz Dev
2025-12-16 14:55:43 -06:00
parent 9228a1f1ea
commit 8aeb33ed2c
6 changed files with 328 additions and 2 deletions
@@ -77,6 +77,8 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
<div id="property-map" class="property-map"> <div id="property-map" class="property-map">
<!-- Leaflet map will be initialized here --> <!-- Leaflet map will be initialized here -->
</div> </div>
<!-- Sticky Filters (below map, visible when main filters scroll out of view) -->
<?php get_template_part('template-parts/property/property-filters-sticky'); ?>
</div> </div>
<div class="property-list-container"> <div class="property-list-container">
<div id="property-results" class="property-results-map"> <div id="property-results" class="property-results-map">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
<?php
/**
* Property Filters - Sticky Version (below map)
*
* Compact filter form that appears when the main filter scrolls out of view.
* Values are synced with the main filter via JavaScript.
*
* @package HomeProz
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Get current filter values from URL (same as main filter)
$current_type = isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '';
$current_location = isset($_GET['property_location']) ? sanitize_text_field($_GET['property_location']) : '';
$current_zip = isset($_GET['zip']) ? sanitize_text_field($_GET['zip']) : '';
$current_min_price = isset($_GET['min_price']) ? intval($_GET['min_price']) : '';
$current_max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
$current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
// Get MLS property types and cities
$property_types = homeproz_get_mls_property_types();
$mls_cities = homeproz_get_mls_cities(50);
?>
<div class="property-filters-sticky" id="property-filters-sticky">
<form class="filters-form-sticky" method="get" action="<?php echo esc_url(get_post_type_archive_link('property')); ?>">
<div class="filters-sticky-grid">
<div class="filter-item-sticky">
<select name="property_type" class="filter-select" aria-label="Property Type">
<option value="">All Types</option>
<?php foreach ($property_types as $type) : ?>
<option value="<?php echo esc_attr($type->property_type); ?>" <?php selected($current_type, $type->property_type); ?>>
<?php echo esc_html($type->property_type); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="filter-item-sticky">
<select name="property_location" class="filter-select" aria-label="City">
<option value="">All Cities</option>
<?php foreach ($mls_cities as $city) : ?>
<option value="<?php echo esc_attr($city); ?>" <?php selected($current_location, $city); ?>>
<?php echo esc_html($city); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="filter-item-sticky">
<select name="beds" class="filter-select" aria-label="Bedrooms">
<option value="">Beds</option>
<option value="1" <?php selected($current_beds, 1); ?>>1+</option>
<option value="2" <?php selected($current_beds, 2); ?>>2+</option>
<option value="3" <?php selected($current_beds, 3); ?>>3+</option>
<option value="4" <?php selected($current_beds, 4); ?>>4+</option>
<option value="5" <?php selected($current_beds, 5); ?>>5+</option>
</select>
</div>
<div class="filter-item-sticky">
<select name="min_price" class="filter-select" aria-label="Minimum Price">
<option value="">Min $</option>
<option value="50000" <?php selected($current_min_price, 50000); ?>>$50k</option>
<option value="100000" <?php selected($current_min_price, 100000); ?>>$100k</option>
<option value="150000" <?php selected($current_min_price, 150000); ?>>$150k</option>
<option value="200000" <?php selected($current_min_price, 200000); ?>>$200k</option>
<option value="250000" <?php selected($current_min_price, 250000); ?>>$250k</option>
<option value="300000" <?php selected($current_min_price, 300000); ?>>$300k</option>
<option value="400000" <?php selected($current_min_price, 400000); ?>>$400k</option>
<option value="500000" <?php selected($current_min_price, 500000); ?>>$500k</option>
</select>
</div>
<div class="filter-item-sticky">
<select name="max_price" class="filter-select" aria-label="Maximum Price">
<option value="">Max $</option>
<option value="100000" <?php selected($current_max_price, 100000); ?>>$100k</option>
<option value="150000" <?php selected($current_max_price, 150000); ?>>$150k</option>
<option value="200000" <?php selected($current_max_price, 200000); ?>>$200k</option>
<option value="250000" <?php selected($current_max_price, 250000); ?>>$250k</option>
<option value="300000" <?php selected($current_max_price, 300000); ?>>$300k</option>
<option value="400000" <?php selected($current_max_price, 400000); ?>>$400k</option>
<option value="500000" <?php selected($current_max_price, 500000); ?>>$500k</option>
<option value="750000" <?php selected($current_max_price, 750000); ?>>$750k</option>
<option value="1000000" <?php selected($current_max_price, 1000000); ?>>$1M+</option>
</select>
</div>
<div class="filter-item-sticky filter-item-sticky-zip">
<input type="text" name="zip" class="filter-input" placeholder="Zip" value="<?php echo esc_attr($current_zip); ?>" maxlength="10" pattern="[0-9\-]*" aria-label="Zip Code">
</div>
</div>
</form>
</div>
@@ -625,6 +625,157 @@
}; };
/**
* Sticky Filters Module
* Shows a compact filter form below the map when the main filter scrolls out of view.
* Desktop only (>= 1024px in map view).
*/
var StickyFilters = {
$mainFilter: null,
$stickyFilter: null,
$mainForm: null,
$stickyForm: null,
observer: null,
isVisible: false,
/**
* Initialize sticky filters
*/
init: function() {
// Only on desktop map view
if (window.innerWidth < 1024 || !$('.is-map-view').length) {
return;
}
this.$mainFilter = $('#property-filters');
this.$stickyFilter = $('#property-filters-sticky');
this.$mainForm = this.$mainFilter.find('.filters-form');
this.$stickyForm = this.$stickyFilter.find('.filters-form-sticky');
if (!this.$mainFilter.length || !this.$stickyFilter.length) {
return;
}
this.setupObserver();
this.bindEvents();
},
/**
* Setup IntersectionObserver on main filter
*/
setupObserver: function() {
var self = this;
this.observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
// Main filter is visible - hide sticky (instant)
self.hideStickyFilter();
} else {
// Main filter scrolled out - show sticky (animated)
self.showStickyFilter();
}
});
}, {
root: null,
rootMargin: '0px',
threshold: 0
});
this.observer.observe(this.$mainFilter[0]);
},
/**
* Bind change events for syncing
*/
bindEvents: function() {
var self = this;
// Sync main form -> sticky form
this.$mainForm.find('select, input').on('change input', function() {
self.syncToSticky(this.name, $(this).val());
});
// Sync sticky form -> main form and trigger filter
this.$stickyForm.find('select').on('change', function() {
self.syncToMain(this.name, $(this).val());
PropertyFilters.filterProperties(1);
});
// Sticky form input (zip) - sync and filter on enter or blur
this.$stickyForm.find('input').on('change', function() {
self.syncToMain(this.name, $(this).val());
PropertyFilters.filterProperties(1);
});
// Prevent form submission (we use AJAX)
this.$stickyForm.on('submit', function(e) {
e.preventDefault();
PropertyFilters.filterProperties(1);
});
},
/**
* Sync a value from main form to sticky form
*/
syncToSticky: function(name, value) {
var $field = this.$stickyForm.find('[name="' + name + '"]');
if ($field.length && $field.val() !== value) {
$field.val(value);
}
},
/**
* Sync a value from sticky form to main form
*/
syncToMain: function(name, value) {
var $field = this.$mainForm.find('[name="' + name + '"]');
if ($field.length && $field.val() !== value) {
$field.val(value);
}
},
/**
* Show sticky filter with fade-in animation (200ms)
*/
showStickyFilter: function() {
if (this.isVisible) return;
this.isVisible = true;
this.$stickyFilter
.removeClass('is-hiding')
.addClass('is-visible');
},
/**
* Hide sticky filter instantly (no animation)
*/
hideStickyFilter: function() {
if (!this.isVisible) return;
this.isVisible = false;
this.$stickyFilter
.removeClass('is-visible')
.addClass('is-hiding');
// Remove hiding class after a tick
var self = this;
setTimeout(function() {
self.$stickyFilter.removeClass('is-hiding');
}, 10);
},
/**
* Sync all values from main to sticky (for initial state)
*/
syncAllToSticky: function() {
var self = this;
this.$mainForm.find('select, input').each(function() {
self.syncToSticky(this.name, $(this).val());
});
}
};
var PropertyFilters = { var PropertyFilters = {
// Elements // Elements
$form: null, $form: null,
@@ -1837,6 +1988,7 @@
ResponsiveView.init(); ResponsiveView.init();
LayoutCalculator.init(); LayoutCalculator.init();
CardImageLoader.init(); CardImageLoader.init();
StickyFilters.init();
// Initialize infinite scroll (all views - desktop map and mobile grid) // Initialize infinite scroll (all views - desktop map and mobile grid)
setTimeout(function() { setTimeout(function() {
@@ -521,6 +521,79 @@
opacity: 0.7; opacity: 0.7;
} }
// Sticky Filters (below map on desktop)
.property-filters-sticky {
display: none; // Hidden by default, shown via JS when main filter scrolls out
@media (min-width: 1024px) {
margin-top: 1rem;
background-color: var(--color-bg-card);
border-radius: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
opacity: 0;
visibility: hidden;
transition: opacity 200ms ease;
&.is-visible {
display: block;
opacity: 1;
visibility: visible;
}
&.is-hiding {
opacity: 0;
visibility: hidden;
transition: none; // Instant hide
}
}
}
.filters-sticky-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
.filter-item-sticky {
.filter-select,
.filter-input {
width: 100%;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
background-color: var(--color-bg-dark);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-accent);
}
}
.filter-select {
padding-right: 1.75rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23B0B0B0' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
}
}
.filter-item-sticky-zip {
.filter-input {
&::placeholder {
color: var(--color-text-muted);
opacity: 0.7;
}
}
}
// Results loading spinner (only for first load) // Results loading spinner (only for first load)
.property-results-loading { .property-results-loading {
display: flex; display: flex;