How to Add Min and Max Dropdown Filters in FacetWP Using fSelect and a Custom Indexer

Blog_Post_Thumbnail_-_How_to_Add_Min_and_Max_Dropdown_Filters_in_FacetWP_Using_fSelect_and_a_Custom_Indexer.png

Filtering listings by price (or any numeric field) is a common requirement in modern WordPress sites—especially for car dealerships, real estate listings, or product catalogs. FacetWP offers several facet types (such as range_list, slider, and fSelect) to help you filter by numeric ranges. However, in some cases, site owners need a dropdown UI for selecting a “minimum” price and a separate dropdown for selecting a “maximum” price. In this tutorial, we’ll:

  1. Explain why built-in facet types like range_list or slider might not be ideal for certain use cases.
  2. Demonstrate an alternative approach using two fSelect dropdown facets (one for “min_price” and one for “max_price”) combined with a custom PHP indexer, ensuring users only see valid threshold values.
  3. Provide step-by-step code snippets, explanations, and best practices.
  4. Answer frequently asked questions at the end.

By the end of this guide, you’ll have a clean, maintainable solution for min/max dropdown filters that avoids confusing UIs and enhances performance.


Why Not Use range_list or slider?

Before diving into the custom approach, it’s important to understand the limitations or trade-offs of FacetWP’s built-in numeric facet types:

  1. Range List (range_list)

    The range_list facet type allows you to define discrete numeric ranges (e.g., $0–$49,999, $50,000–$99,999, etc.) and present them as checkboxes, radio buttons, or dropdown options. However:

    • If you need truly dynamic thresholds (for example, increments of $50k up to $1 million, then $500k increments thereafter), manually defining each range becomes tedious.
    • Users cannot select open-ended combinations like “Minimum $150k” independently of a maximum; they must choose from preconfigured ranges.
    • Mapping custom increments to an intuitive UI may be cumbersome, and the facet choices won’t adapt if product prices change over time—unless you manually update the ranges.
  2. Slider

    The slider facet type (powered by noUiSlider) offers an interactive UI with two handles for selecting minimum and maximum values FacetWP. But:

    • Sliders can be difficult to use accurately on mobile devices or for users who want to type exact numbers.
    • With large numeric ranges, the slider’s sensitivity can make it hard to pick, say, exactly $500 000 vs. $550 000.
    • The slider UI may not match the design aesthetic of dropdown menus already used elsewhere on a site.
  3. Custom UI Requirements

    • If the requirement is strictly two dropdowns (one for “Min Price” and another for “Max Price”)—and you want those dropdowns to only show valid thresholds (e.g., if a listing’s price is $175k, it should appear under every "min_price" option up to $175k, but for "max_price" only for every threshold ≥ $175k)—the built-in facets do not natively support that logic.

For scenarios where your UX demands distinct “Min” and “Max” dropdowns, with consistent increments, and you want to ensure every dropdown value corresponds to an actual numeric threshold, a fSelect-based approach combined with a custom indexer is ideal.


Introducing fSelect-Based Min/Max Dropdowns

The fSelect facet type generates a searchable dropdown that supports single-select or multi-select modes. In our scenario, we’ll configure two separate fSelect facets:

  1. min_price (dropdown listing thresholds from $0 to $3 million in defined increments)
  2. max_price (same thresholds, but selected independently)

Using fSelect ensures:

  • A clean, compact UI: dropdowns take up less space than a full checklist or slider.
  • Users can search threshold values (e.g., typing “500” shows “$500 000”).
  • FacetWP can index each listing’s price under multiple facet values, enabling dynamic threshold logic (more on this below).

How It Works

  1. Threshold Generation (PHP):

    We define a PHP function, get_price_thresholds(), that returns an array of numeric thresholds:

    • $0 → $1 000 000 in $50 000 increments
    • $1 000 000 → $3 000 000 in $500 000 increments
  2. Indexing Logic (PHP):

    Via the facetwp_index_row filter, for each listing (post), we retrieve its actual price (e.g., $175 000).

    • For min_price, we loop through thresholds and insert a facet row for every threshold the listing’s price (e.g., $0, $50 000, $100 000, $150 000).
    • For max_price, we loop through thresholds and insert a facet row for every threshold the listing’s price (e.g., $200 000, $250 000, … up to $3 000 000).

    This way, if a user picks “Min $100 000”, every listing with price ≥ $100 000 will match, and if they pick “Max $500 000”, every listing with price ≤ $500 000 will match.

  3. Facet Definition (Admin Settings / PHP):

    We create two fSelect facets—min_price and max_price—targeting the same data source (the custom field or metadata where the price is stored). We sort each by the raw facet_value (the numeric threshold) so that the dropdown lists increment values ascending.

  4. Ordering (PHP):

    By default, FacetWP orders facet values alphabetically (e.g., “$1 000” might appear before “$100”). We override ordering for our facets via facetwp_facet_orderby to cast facet_value to an unsigned integer and sort ascending.

The result is two intuitive dropdowns:

  • Min Price: [Any Min Price, $0, $50 000, $100 000, …]
  • Max Price: [Any Max Price, $50 000, $100 000, $150 000, …]

Step 1: Define Price Thresholds in PHP

First, we need a centralized function that returns the numeric thresholds used by both facets. Place this code in your child theme’s functions.php (or in a custom plugin):

/**
 * Return an array of numeric price thresholds.
 *
 * - 0 → 1,000,000 in 50,000 increments.
 * - 1,000,000 → 3,000,000 in 500,000 increments.
 *
 * @return array<int> List of thresholds.
 */
function get_price_thresholds(): array {
    $thresholds = [];

    // 0 → 1,000,000 in 50,000 increments:
    foreach ( range( 0, 1000000, 50000 ) as $t ) {
        $thresholds[] = $t;
    }

    // 1,000,000 → 3,000,000 in 500,000 increments:
    foreach ( range( 1500000, 3000000, 500000 ) as $t ) {
        $thresholds[] = $t;
    }

    return $thresholds;
}

Explanation:

  • We use range(0, 1000000, 50000) to generate [0, 50000, 100000, 150000, …, 1000000].
  • Then range(1500000, 3000000, 500000) generates [1500000, 2000000, 2500000, 3000000].
  • Combining both yields an array of integers that represent every 50k increment up to 1 million, then every 500k increment to 3 million.

This function will be called in our indexer to know which thresholds to assign to each listing based on its actual price.


Step 2: Create fSelect Facets for Min and Max

Next, define two fSelect facets in FacetWP’s settings (or programmatically if you prefer). In the WordPress dashboard:

  1. Go to: Settings → FacetWP → Facets
  2. Click “Add New” to create a new facet.
  3. Configure “Min Price” facet:
    • Name: min_price
    • Label: Any Min Price (this is what displays when no selection)
    • Facet type: fSelect
    • Data source: Choose the custom field key or ACF field that stores the listing’s price (e.g., meta:price or acf/{field_key})
    • Label Any: Min Price (text that appears above or inside the dropdown before a selection)
    • Count: 20 (since we have ~30 thresholds, this shows 20 at a time; fSelect will allow scrolling)
  4. Repeat for “Max Price” facet:
    • Name: max_price
    • Label: Any Max Price
    • Facet type: fSelect
    • Same Data source as above
    • Label Any: Max Price
    • Count: 20

These definitions tell FacetWP that the facet values depend on the same underlying data source (the price meta). However, we will override how values are generated via our custom indexer in Step 3.

Why fSelect instead of regular Dropdown?

  • The fSelect facet type offers a searchable dropdown with built-in virtualization for large lists, ensuring smooth UX for ~30-40 dropdown options.
  • Unlike the basic “Dropdown” facet, fSelect will load only the visible portion of the list, speeding up page loads and reducing DOM size.

Step 3: Customize FacetWP Indexing via PHP

By default, FacetWP indexes each post with a single data point for a numeric field. We need to override that behavior so that each post (listing) appears under multiple “threshold” values. For example, a post with price $175 000 should be indexed under:

  • For min_price: 0, 50 000, 100 000, 150 000
  • For max_price: 200 000, 250 000, 300 000, … up to 3 000 000

Add the following code to your functions.php:

php
CopyEdit
/**
 * Customize FacetWP indexing for min_price and max_price facets.
 *
 * For each listing, retrieve its actual price and assign it to all thresholds
 * that satisfy the min_price (<= price) or max_price (>= price) conditions.
 *
 * @param array $params Parameters including 'facet_name', 'post_id', 'facet_value'.
 * @param object $class  FacetWP indexer class.
 * @return array|false Modified $params or false to skip default indexing.
 */
add_filter( 'facetwp_index_row', function( array $params, $class ) {
    // Only target our two price-based facets
    if ( ! in_array( $params['facet_name'], [ 'min_price', 'max_price' ], true ) ) {
        return $params;
    }

    // Retrieve the actual price from post meta (ensure numeric)
    $price = floatval( get_post_meta( $params['post_id'], 'price', true ) );
    if ( ! $price ) {
        // Skip indexing if price is zero or missing
        return false;
    }

    // Get our predefined thresholds
    $thresholds = get_price_thresholds();

    if ( 'min_price' === $params['facet_name'] ) {
        foreach ( $thresholds as $min ) {
            // If price is greater than or equal to threshold, index under that threshold
            if ( $price >= $min ) {
                $params['facet_value']         = $min;
                $params['facet_display_value'] = '$' . number_format_i18n( $min ); // Example: $150,000
                $class->insert( $params );
            }
        }
    } else { // max_price
        foreach ( $thresholds as $max ) {
            // If price is less than or equal to threshold, index under that threshold
            if ( $price <= $max ) {
                $params['facet_value']         = $max;
                $params['facet_display_value'] = '$' . number_format_i18n( $max );
                $class->insert( $params );
            }
        }
    }

    // Return false to prevent default single-value indexing
    return false;
}, 10, 2 );

How This Works

  1. Hook into facetwp_index_row****:

    • FacetWP calls this filter for every post (or term) and every facet. $params['facet_name'] indicates which facet is being processed.
    • By returning false, we prevent FacetWP from performing its default “single value” index insertion. Instead, we manually insert multiple values via $class->insert( $params ).
  2. Retrieve the Listing Price:

    • We use get_post_meta( $params['post_id'], 'price', true ) to fetch the price. Adjust this if you store price under another meta key or an ACF field.
  3. Loop Through Thresholds:

    • For min_price, we assign every threshold the actual price. This ensures that if a user selects “Min $100 000”, this listing is included (because $175 000 ≥ $100 000).
    • For max_price, we assign every threshold the actual price. This ensures that if a user selects “Max $500 000”, the listing is included as long as $175 000 ≤ $500 000.
  4. Set facet_value and facet_display_value****:

    • facet_value must be the raw numeric threshold (e.g., 150000).
    • facet_display_value is what the dropdown shows (formatted currency, e.g., $150,000). You can localize formatting (e.g., wect_get_currency() . ntt_number_format($min) in original client code); here we use WordPress’s number_format_i18n().
  5. Inserting Multiple Rows:

    Each call to $class->insert( $params ) registers a facet row under the specified threshold. This is how FacetWP knows to show this listing when filtering by any of the applicable thresholds.


Step 4: Ensure Proper Ordering of Dropdown Options

FacetWP normally orders facet values alphabetically. A numeric threshold field stored as a string might produce incorrect ordering (e.g., “$100 000” vs. “$50 000”). We need to override the ORDER BY clause so that FacetWP casts the facet_value to an integer and sorts ascending. Add this code to functions.php:

/**
 * Order the min_price and max_price facets by numeric value (ascending).
 *
 * @param string $orderby The existing ORDER BY clause.
 * @param array  $facet   Facet settings.
 * @return string Modified ORDER BY clause.
 */
add_filter( 'facetwp_facet_orderby', function( string $orderby, array $facet ) {
    if ( in_array( $facet['name'], [ 'min_price', 'max_price' ], true ) ) {
        // Cast facet_value to unsigned integer and sort ascending
        $orderby = 'CAST(f.facet_value AS UNSIGNED) ASC';
    }
    return $orderby;
}, 10, 2 );

Explanation:

  • We detect if the current facet is min_price or max_price.
  • We replace $orderby with CAST(f.facet_value AS UNSIGNED) ASC. “f.facet_value” refers to the underlying database column where FacetWP stores each indexed value. Casting it ensures numeric ascending order (e.g., 0, 50000, 100000, …, 3000000).

Putting It All Together

By now, you have:

  1. Defined numeric thresholds in get_price_thresholds().
  2. Created two fSelect facets (min_price and max_price) in FacetWP’s dashboard, targeting your price meta/ACF field.
  3. Added a custom indexer (facetwp_index_row) to map each listing’s price to multiple threshold values.
  4. Ensured proper ordering of dropdown options via facetwp_facet_orderby.

Below is a consolidated view of the code in functions.php. Adjust formatting or field keys as needed.

<?php
/**
 * 1. Return an array of numeric price thresholds.
 */
function get_price_thresholds(): array {
    $thresholds = [];

    // 0 → 1,000,000 in 50,000 increments:
    foreach ( range( 0, 1000000, 50000 ) as $t ) {
        $thresholds[] = $t;
    }

    // 1,000,000 → 3,000,000 in 500,000 increments:
    foreach ( range( 1500000, 3000000, 500000 ) as $t ) {
        $thresholds[] = $t;
    }

    return $thresholds;
}

/**
 * 2. Customize FacetWP indexing for min_price and max_price facets.
 */
add_filter( 'facetwp_index_row', function( array $params, $class ) {
    if ( ! in_array( $params['facet_name'], [ 'min_price', 'max_price' ], true ) ) {
        return $params;
    }

    // Retrieve listing price from post meta (or use ACF function if necessary)
    $price = floatval( get_post_meta( $params['post_id'], 'price', true ) );
    if ( ! $price ) {
        return false;
    }

    $thresholds = get_price_thresholds();

    if ( 'min_price' === $params['facet_name'] ) {
        foreach ( $thresholds as $min ) {
            if ( $price >= $min ) {
                $params['facet_value']         = $min;
                $params['facet_display_value'] = '$' . number_format_i18n( $min );
                $class->insert( $params );
            }
        }
    } else { // max_price
        foreach ( $thresholds as $max ) {
            if ( $price <= $max ) {
                $params['facet_value']         = $max;
                $params['facet_display_value'] = '$' . number_format_i18n( $max );
                $class->insert( $params );
            }
        }
    }

    return false;
}, 10, 2 );

/**
 * 3. Order the min_price and max_price facets by numeric value.
 */
add_filter( 'facetwp_facet_orderby', function( string $orderby, array $facet ) {
    if ( in_array( $facet['name'], [ 'min_price', 'max_price' ], true ) ) {
        $orderby = 'CAST(f.facet_value AS UNSIGNED) ASC';
    }
    return $orderby;
}, 10, 2 );

After adding this code, rebuild your FacetWP index:

  1. Go to FacetWP → Settings.
  2. Click “Re-index”.
  3. Wait for indexing to complete.

Now navigate to your listing template (where you placed the facetwp_display() calls). You should see two dropdowns:

  • Min Price: lists thresholds from $0, $50 000, $100 000, …
  • Max Price: same list.

A user can now select a “Min Price” and/or a “Max Price,” and the results will update dynamically to show only listings within the chosen range.


Styling and UX Considerations

  1. Dropdown Placement:

    • Place the Min Price dropdown to the left of the Max Price dropdown, with a descriptive label (“Price Range”) above or to the side.

    • Example HTML in your listing template:

      <div class="facetwp-filters">
          <label for="min_price">Min Price</label>
          <?php echo facetwp_display( 'facet', 'min_price' ); ?>
      
          <label for="max_price">Max Price</label>
          <?php echo facetwp_display( 'facet', 'max_price' ); ?>
      </div>
      
  2. Synchronized Dropdown Height:

    • Ensure both fSelect dropdowns have the same visual height for consistency. Use CSS like:

      .facetwp-filters .facetwp-facet {
          display: inline-block;
          width: 48%;
          margin-right: 2%;
          vertical-align: top;
      }
      .facetwp-filters .facetwp-facet:last-child {
          margin-right: 0;
      }
      
  3. Placeholder Text & Labels:

    • Use Label Any (e.g., “Any Min Price” / “Any Max Price”) so that when no selection is made, it’s clear the dropdown is unfiltered.
    • Consider adding placeholder tips (e.g., “Select minimum price” as the first disabled option).
  4. Mobile Responsiveness:

    • On smaller screens, stack the dropdowns vertically. Example:

      @media (max-width: 600px) {
          .facetwp-filters .facetwp-facet {
              display: block;
              width: 100%;
              margin-bottom: 1rem;
          }
      }
      
  5. Default Selection Logic:

    • If both “Min Price” and “Max Price” are selected simultaneously, FacetWP automatically applies an AND relationship, so only listings between those thresholds appear.
    • If you want to hide the “Max Price” dropdown until a “Min Price” is selected (or vice versa), consider using JavaScript to toggle visibility.

Performance and Indexing Tips

  1. Re-index After Changes:
    • Whenever you modify the threshold logic or change the price meta key, re-index FacetWP to ensure the new facet values are populated.
  2. Threshold Volume:
    • Our example uses ~30 thresholds. If you expect 100+ thresholds, you may need to adjust the count parameter in the facet settings or consider server-side optimizations (multiple pages of dropdown).
    • The fSelect facet loads options asynchronously; thus, a larger number of thresholds is acceptable but monitor performance.
  3. Custom “Ghost” Options:
    • By setting "ghosts" => "no" and "preserve_ghosts" => "no" in the facet definition, only thresholds with at least one matching listing will be shown. If you want to show all thresholds regardless of matches, enable “ghosts.”
  4. Caching Considerations:
    • If your site uses a full-page caching plugin (like WP Rocket or W3 Total Cache), ensure that FacetWP’s AJAX endpoint (/wp-admin/admin-ajax.php?action=facetwp) is excluded from caching. Otherwise, filtering may produce stale results.
  5. Large Data Sets:
    • For sites with thousands of listings, consider limiting thresholds to meaningful increments (e.g., $100k increments) or add logic to dynamically generate thresholds based on actual price distribution.
    • Example: If no listing is priced above $1 million, omit thresholds above $1 million to reduce dropdown size.

Conclusion

Using two fSelect facets (“Min Price” and “Max Price”) combined with a custom indexer in PHP allows you to create precise, user-friendly price range filters without relying on preconfigured range_list or potentially confusing slider facets. This approach:

  • Delivers a simple, familiar dropdown interface.
  • Dynamically maps each listing to multiple threshold values, ensuring accurate filtering.
  • Maintains performance by indexing numeric thresholds and casting facet values for proper ordering.
  • Offers flexibility to adjust increments in one central function (get_price_thresholds()).

While FacetWP’s built-in facet types serve many use cases, this custom method excels when you need separate “Min” and “Max” dropdowns that adapt to your numeric data distribution. Whether you’re building a car dealership site, a real estate listing, or any catalog that requires price-based filtering, this pattern can be extended to other numeric fields (e.g., square footage, product weight, or any metric).


FAQ

  1. Can I use a range_list facet instead of fSelect for min/max dropdowns?

    While the range_list facet allows defining custom numeric ranges and displaying them as checkboxes, radio buttons, or even dropdowns, it doesn’t support separate “Min” and “Max” dropdowns out of the box. Each “range” in a range_list is a closed set (e.g., $50k–$100k), so a user cannot independently select “Min $50 000” and “Max $300 000.” The fSelect approach with a custom indexer allows exactly that—two independent dropdowns that combine via AND logic.

  2. Why not use the built-in slider facet to pick min and max?

    The slider facet (powered by noUiSlider) offers a dual-handle interface for numeric ranges. However, sliders can be imprecise on mobile devices, require extra JavaScript customizations for labeling, and may not fit designs where dropdowns are preferred. Additionally, sliders always display the full numeric continuum, which can be confusing if you want discrete thresholds (e.g., increments of $50k). Using fSelect ensures discrete, searchable dropdown options that are consistent across devices.

  3. My “Min Price” dropdown shows thresholds that yield no results. How do I hide them?

    By default, the example code uses "ghosts" => "no" and "preserve_ghosts" => "no", so FacetWP won’t display options (thresholds) with zero matching posts. If you want to show every threshold regardless of matches, set "ghosts" => "yes" in your facet settings. Otherwise, ensure re-indexing is complete so ghost logic functions correctly.

  4. How can I adjust thresholds dynamically based on my current listings’ price range?

    Instead of hardcoding range(0, 1000000, 50000), you can:

    1. Query for the minimum and maximum price across all posts.

    2. Determine a sensible increment (e.g., divide the span by 20 increments).

    3. Generate thresholds programmatically:

      $min_price = intval( get_post_meta( get_min_price_post_id(), 'price', true ) );
      $max_price = intval( get_post_meta( get_max_price_post_id(), 'price', true ) );
      $increment = ceil( ( $max_price - $min_price ) / 20 );
      $thresholds = range( $min_price, $max_price, $increment );
      
    4. Replace get_price_thresholds() logic with this dynamic generation, then re-index FacetWP.

  5. How do I pre-select a “Min Price” and “Max Price” on page load?

    Use the facetwp_preload_url_vars filter to add URL variables programmatically. Example:

    add_filter( 'facetwp_preload_url_vars', function( $url_vars ) {
        if ( '/car-listings' === FWP()->helper->get_uri() ) {
            if ( empty( $url_vars['min_price'] ) ) {
                $url_vars['min_price'] = [ '150000' ]; // Preselect $150,000
            }
            if ( empty( $url_vars['max_price'] ) ) {
                $url_vars['max_price'] = [ '500000' ]; // Preselect $500,000
            }
        }
        return $url_vars;
    } );