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