Parent and Child Select elelents

I have a Select element with parent terms, and another where I want to show the child elements of the parent selected in the first select.

So far, all I can get is a list of the label(?). I think it’s the query part I do not understand well enough
Many thanks

Hi Pete,

thanks for your question! Let me break this down into two parts.

1. Why you only see labels

When you populate a Select via the nestable Option element with a query loop, the Option element has two separate controls: Label and Value. If you only fill the Label, the value falls back to the label text. For a terms loop, you’d typically set:

  • Label: {term_name}
  • Value: {term_id} (or {term_slug}, depending on what you need on submit)

2. Parent → Child dependency

A fully dynamic “cascading” select (where the second select re-queries its options live based on what was picked in the first one) is not something the Select field does out of the box — the options are rendered server-side when the page loads, so the second select can’t re-fetch terms after a choice is made in the browser.

The way to build this with the current tools is with Conditions:

  1. Your first Select lists the parent terms (Option element with a terms query, Value = {term_id}).
  2. For each parent term, add one additional Select for the children. Inside each of those, use an Option element with a query loop → Terms query → your taxonomy → set Child of to the ID of that parent term (this control also supports dynamic data).
  3. On each child Select, open the Conditions group and add a condition: Form Field → the ID of your parent select → Is Equal → the parent term ID. This shows exactly the child select that matches the current parent selection, and it updates instantly when the user changes the first select.
  4. In the form’s Actions settings, enable “Ignore Conditionally Hidden Fields” so the hidden child selects don’t end up in your submission data.

This works well as long as the number of parent terms is manageable. If you have a very large or frequently changing set of parent terms, this approach gets tedious — in that case let me know a bit more about your setup and I’ll try to help you find the leanest structure.

Best,
Daniele

Wow, this is something I wouldn’t have thought of in a month of Sundays!
My first select returns 18 regions of France. Although I would only have to set this once, setting 18 ‘second’ selects feels wrong. I have achieved what I need with some JS, but I always prefer to use existing Builder elements/controls if possible.

I wrote the javascript and got AI to refactor and document it. I had lots of problems with the ‘modern’ select option and had to get AI to solve that, otherwise it wasn’t too bad and may help others with things like States/Counties, Countries/Citys etc.

Here is the JS that might help others

/**
 * Cascading Region → Department select - this is a BricksForge select element using the queryloop to select Terms from in my case Regions of France and their Departments, but could be anything really as long as it is a single parent child taxonomy.
 *
 * When the Region changes, loads its child Departments from the WordPress REST API
 * (`/wp/v2/property-region?parent={id}`) and rebuilds the Department <select>.
 *
 * If the field is enhanced with Choices.js (e.g. Bricksforge “modern select”),
 * Choices must be destroyed before options change and re-created afterwards —
 * otherwise the visible dropdown stays empty or stuck on “Loading…”.
 *
 * Public API:
 *   fdInitCascadingDepartments()     — bind + populate (safe to call more than once)
 *   fdCascadingDepartmentStub()      — thin entry point (same as init)
 *   fdCascadingDepartmentEnabled     — set false before load to disable
 *
 * Adapt REGION_SELECTORS / DEPT_SELECTORS / restUrl() to your field names and taxonomy.
 */
(function () {
  "use strict";
  /* This allowed me to turn it off during testing */
  window.fdCascadingDepartmentEnabled =
    typeof window.fdCascadingDepartmentEnabled === "boolean"
      ? window.fdCascadingDepartmentEnabled
      : true;

  /** Candidate CSS selectors for the Region <select> (first match wins). */
  var REGION_SELECTORS = [
    'select[name="form-field-property_region"]',
    'select[name="form-field-prop_region"]',
    'select[name="form-field-kedeot"]',
  ];

  /** Candidate CSS selectors for the Department <select> (first match wins). */
  var DEPT_SELECTORS = [
    'select[name="form-field-property_department"]',
    'select[name="form-field-prop_department"]',
  ];

  var PLACEHOLDER_NO_REGION = "— Select region first —";
  var PLACEHOLDER_DEPT = "— Select department —";
  var PLACEHOLDER_LOADING = "— Loading… —";

  /** Returns the first element matching any selector in the list, or null. */
  function firstMatch(selectors) {
    for (var i = 0; i < selectors.length; i++) {
      var el = document.querySelector(selectors[i]);
      if (el) return el;
    }
    return null;
  }

  /** True when the value is a whole-number term ID (not a slug). */
  function isNumericId(value) {
    return /^[0-9]+$/.test(String(value));
  }

  /**
   * Finds the live Choices.js instance for a <select>, if any.
   * Checks our own `_choices` stash first, then the `.choices` wrapper.
   */
  function getChoicesInstance(select) {
    if (!select) return null;
    if (select._choices && typeof select._choices.destroy === "function") {
      return select._choices;
    }
    var wrap = select.closest(".choices");
    if (wrap && wrap.choices && typeof wrap.choices.destroy === "function") {
      return wrap.choices;
    }
    return null;
  }

  /**
   * Removes Choices.js from a <select> and restores a plain native control.
   * Must run before changing options — editing innerHTML while Choices owns
   * the field leaves the visible UI out of sync.
   */
  function destroyChoices(select) {
    if (!select) return;

    var instance = getChoicesInstance(select);
    if (instance && typeof instance.destroy === "function") {
      try {
        instance.destroy();
      } catch (e) {
        /* fall through to DOM unwrap */
      }
    }

    select._choices = null;

    var stale = select.closest(".choices");
    if (stale && stale.parentNode) {
      select.hidden = false;
      select.removeAttribute("hidden");
      select.removeAttribute("data-choice");
      select.removeAttribute("data-choices-initialized");
      select.removeAttribute("data-custom-properties");
      select.classList.remove("choices__input");
      select.style.display = "";
      select.tabIndex = 0;
      stale.parentNode.insertBefore(select, stale);
      stale.remove();
    }

    select.removeAttribute("data-choices-initialized");
    select.removeAttribute("data-choice");
  }

  /**
   * Re-wraps a <select> with Choices.js after options have been updated.
   * Preserves disabled state and the currently selected value when possible.
   * No-op if Choices is not on the page.
   */
  function initChoices(select) {
    if (!select || typeof window.Choices !== "function") return;

    destroyChoices(select);

    var wasDisabled = !!select.disabled;
    var currentValue = select.value;

    var next = new window.Choices(select, {
      searchEnabled: true,
      itemSelectText: "",
      shouldSort: false,
      allowHTML: false,
    });

    select._choices = next;
    var wrap = select.closest(".choices");
    if (wrap) wrap.choices = next;

    if (currentValue) {
      try {
        next.setChoiceByValue(currentValue);
      } catch (e) {
        /* ignore missing value */
      }
    }

    if (wasDisabled) next.disable();
    else next.enable();
  }

  /**
   * Replaces all options on the Department <select> with a placeholder plus
   * the given department terms. Destroys Choices first so the native DOM
   * can be rewritten safely (call initChoices afterwards to refresh the UI).
   */
  function setNativeOptions(select, placeholderText, departments) {
    destroyChoices(select);

    select.innerHTML = "";
    var placeholder = document.createElement("option");
    placeholder.value = "";
    placeholder.textContent = placeholderText;
    select.appendChild(placeholder);

    (departments || []).forEach(function (dept) {
      var option = document.createElement("option");
      option.value = dept.slug || String(dept.id);
      option.textContent = dept.name;
      option.setAttribute("data-term-id", String(dept.id));
      select.appendChild(option);
    });
  }

  /**
   * Builds a full REST URL for the property-region taxonomy endpoint.
   * Uses wpApiSettings / the api.w.org discovery link when available.
   */
  function restUrl(query) {
    var root =
      (window.wpApiSettings && window.wpApiSettings.root) ||
      (document.querySelector('link[rel="https://api.w.org/"]') &&
        document.querySelector('link[rel="https://api.w.org/"]').href) ||
      "/wp-json/";
    if (root.slice(-1) !== "/") root += "/";
    return root + "wp/v2/property-region?" + query;
  }

  /** Fetches a URL and resolves with parsed JSON, or rejects on non-OK status. */
  function fetchJson(url) {
    return fetch(url, { credentials: "same-origin" }).then(function (res) {
      if (!res.ok) throw new Error("HTTP " + res.status);
      return res.json();
    });
  }

  /**
   * Maps a Region field value to a taxonomy term ID.
   * Accepts a numeric ID as-is, or resolves a slug via REST (with a small cache).
   */
  function resolveRegionTermId(regionValue) {
    if (!regionValue) return Promise.resolve(0);

    if (isNumericId(regionValue)) {
      return Promise.resolve(Number(regionValue));
    }

    var cached = (window.fdRegionTermIdBySlug || {})[regionValue];
    if (cached) return Promise.resolve(cached);

    return fetchJson(
      restUrl("slug=" + encodeURIComponent(regionValue) + "&_fields=id,slug"),
    ).then(function (rows) {
      var id = rows && rows[0] ? Number(rows[0].id) : 0;
      window.fdRegionTermIdBySlug = window.fdRegionTermIdBySlug || {};
      if (id) window.fdRegionTermIdBySlug[regionValue] = id;
      return id;
    });
  }

  /**
   * Loads Department terms whose parent is the given Region term ID.
   * Returns an array of { id, slug, name } (empty if parentId is falsy).
   */
  function fetchChildDepartments(parentId) {
    if (!parentId) return Promise.resolve([]);
    return fetchJson(
      restUrl(
        "parent=" +
          encodeURIComponent(parentId) +
          "&per_page=100&orderby=name&order=asc&_fields=id,slug,name",
      ),
    ).then(function (rows) {
      return Array.isArray(rows) ? rows : [];
    });
  }

  /**
   * Main cascade step: read the Region value, fetch its Departments, rewrite
   * the Department <select>, then re-init Choices.
   *
   * preserveValue — when true, tries to keep the current/prefilled Department
   * after the list reloads (edit forms). A sequence counter ignores stale
   * responses if the user changes Region again before the request finishes.
   */
  function populateDepartments(regionSelect, deptSelect, preserveValue) {
    var regionValue = regionSelect.value;
    var keep = preserveValue
      ? deptSelect.value || deptSelect.getAttribute("data-fd-prefill") || ""
      : "";
    var seq = (Number(deptSelect.dataset.fdCascadeSeq) || 0) + 1;
    deptSelect.dataset.fdCascadeSeq = String(seq);

    if (!regionValue) {
      deptSelect.disabled = true;
      setNativeOptions(deptSelect, PLACEHOLDER_NO_REGION, []);
      initChoices(deptSelect);
      return Promise.resolve();
    }

    deptSelect.disabled = true;
    setNativeOptions(deptSelect, PLACEHOLDER_LOADING, []);
    initChoices(deptSelect);

    return resolveRegionTermId(regionValue)
      .then(function (parentId) {
        return fetchChildDepartments(parentId);
      })
      .then(function (departments) {
        if (deptSelect.dataset.fdCascadeSeq !== String(seq)) return;

        deptSelect.disabled = departments.length === 0;
        setNativeOptions(deptSelect, PLACEHOLDER_DEPT, departments);

        if (keep) {
          deptSelect.value = keep;
          if (deptSelect.value !== keep) {
            for (var i = 0; i < departments.length; i++) {
              if (
                String(departments[i].id) === String(keep) ||
                departments[i].name === keep ||
                departments[i].slug === keep
              ) {
                deptSelect.value =
                  departments[i].slug || String(departments[i].id);
                break;
              }
            }
          }
        }

        initChoices(deptSelect);
      })
      .catch(function () {
        if (deptSelect.dataset.fdCascadeSeq !== String(seq)) return;
        deptSelect.disabled = true;
        setNativeOptions(deptSelect, PLACEHOLDER_NO_REGION, []);
        initChoices(deptSelect);
      });
  }

  /**
   * Locates Region + Department fields, binds the Region `change` listener
   * once, and runs an initial populate (e.g. for a pre-selected Region).
   */
  window.fdInitCascadingDepartments = function fdInitCascadingDepartments() {
    if (window.fdCascadingDepartmentEnabled === false) return;

    var regionSelect = firstMatch(REGION_SELECTORS);
    var deptSelect = firstMatch(DEPT_SELECTORS);
    if (!regionSelect || !deptSelect) return;

    if (regionSelect.dataset.fdCascadeBound === "1") {
      populateDepartments(regionSelect, deptSelect, true);
      return;
    }
    regionSelect.dataset.fdCascadeBound = "1";

    regionSelect.addEventListener("change", function () {
      populateDepartments(regionSelect, deptSelect, false);
    });
    populateDepartments(regionSelect, deptSelect, true);
  };

  /**
   * Optional stub entry for a page Code element: call this after the script
   * loads (or if fields appear late). Delegates to fdInitCascadingDepartments.
   */
  window.fdCascadingDepartmentStub = function fdCascadingDepartmentStub() {
    window.fdInitCascadingDepartments();
  };

  /** Starts the cascade once the DOM is ready. */
  function boot() {
    window.fdCascadingDepartmentStub();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();

Thanks
Pete