diff --git a/docs/_data/site-navigation.json b/docs/_data/site-navigation.json
index 1ba099785f..440322e363 100755
--- a/docs/_data/site-navigation.json
+++ b/docs/_data/site-navigation.json
@@ -166,6 +166,10 @@
"title": "Checkbox & Radio",
"url": "/product/components/checkbox/"
},
+ {
+ "title": "Conditional Fields",
+ "url": "/product/components/conditional-fields/"
+ },
{
"title": "Inputs",
"url": "/product/components/inputs/"
diff --git a/docs/product/components/conditional-fields.html b/docs/product/components/conditional-fields.html
new file mode 100644
index 0000000000..0a5833a3d9
--- /dev/null
+++ b/docs/product/components/conditional-fields.html
@@ -0,0 +1,87 @@
+---
+layout: page
+js: true
+title: Conditional Fields
+description: Forms occasionally need to support branching meaning that extra fields will be displayed depending on the values entered to previous fields.
+---
+
+
+ {% tip "mb24" %}
+ Accessibility Consideration: There is currently no accepted approach for creating conditional fields that does not introduce accessibility concerns for Assistive Technology users. It is recommended that large forms that have complex branching be designed to span across multiple pages; read Gov.UK's article on conditionally revealed questions for more information.
+ {% endtip %}
+
+ {% header "h2", "Radio Buttons" %}
+ {% capture example_one %}
+
+ {% endcapture %}
+
+
+ {{ example_one | highlight: "html" }}
+
+ {{ example_one }}
+
+
+
diff --git a/lib/ts/controllers/index.ts b/lib/ts/controllers/index.ts
index 41f17727ee..9d58b7d67e 100644
--- a/lib/ts/controllers/index.ts
+++ b/lib/ts/controllers/index.ts
@@ -1,4 +1,5 @@
// export all controllers *with helpers* so they can be bulk re-exported by the package entry point
+export { ConditionalField } from "./s-conditional-field";
export { ExpandableController } from "./s-expandable-control";
export { hideModal, ModalController, showModal } from "./s-modal";
export { hideBanner, BannerController, showBanner } from "./s-banner";
diff --git a/lib/ts/controllers/s-conditional-field.ts b/lib/ts/controllers/s-conditional-field.ts
new file mode 100644
index 0000000000..c4957c6dff
--- /dev/null
+++ b/lib/ts/controllers/s-conditional-field.ts
@@ -0,0 +1,118 @@
+import { StacksController } from "../stacks";
+import { assumeType } from "../utilities/helpers";
+
+type Operator = '==' | '!=' | 'checked';
+type FormFieldElement =
+ | HTMLInputElement
+ | HTMLSelectElement
+ | HTMLTextAreaElement;
+
+const SupportedTags = ['input', 'select', 'textarea'];
+
+export class ConditionalField extends StacksController {
+ declare targetIdValue: string;
+ declare operatorValue: Operator;
+ declare expectedValue: string;
+
+ static values = {
+ targetId: String,
+ operator: String,
+ expected: String,
+ };
+
+ private formField: FormFieldElement | FormFieldElement[] | null = null;
+
+ connect() {
+ const targetEl = document.getElementById(this.targetIdValue);
+
+ if (targetEl === null) {
+ throw new Error(`No element with ID ${this.targetIdValue} found.`);
+ }
+
+ if (!SupportedTags.includes(targetEl.tagName.toLowerCase())) {
+ throw new Error(`A ConditionalField controller can only target ${SupportedTags.join(', ')} tags; got ${targetEl.tagName}`);
+ }
+
+ assumeType(targetEl);
+
+ // If we have a set of radio buttons, they will change automatically
+ // whenever a different radio is selected but the radios that were
+ // unselected do not fire a "change" event. So we should watch all radios
+ // named the same way so that we can watch for changes.
+ if (targetEl.type === 'radio') {
+ this.formField = Array.from(document.querySelectorAll(`input[type="radio"][name="${targetEl.name}"]`));
+
+ for (const formFieldElement of this.formField) {
+ formFieldElement.addEventListener('change', this.handleChangeEvent);
+ }
+ } else {
+ this.formField = targetEl;
+ this.formField.addEventListener('change', this.handleChangeEvent);
+ }
+ }
+
+ disconnect() {
+ if (Array.isArray(this.formField)) {
+ for (const formFieldElement of this.formField) {
+ formFieldElement.removeEventListener('change', this.handleChangeEvent);
+ }
+ } else {
+ this.formField?.removeEventListener('change', this.handleChangeEvent);
+ }
+ }
+
+ private handleChangeEvent = (event: Event) => {
+ if (event.currentTarget === null) {
+ return;
+ }
+
+ // currentTarget has a type of `EventTarget`, which only has event listener
+ // methods defined in the interface.
+ assumeType(event.currentTarget);
+
+ const value = this.getValueFromFormField(event.currentTarget);
+ const shouldShow = this.doesMatch(value, this.operatorValue, this.expectedValue);
+
+ if (shouldShow) {
+ this.element.classList.remove('d-none');
+ } else {
+ this.element.classList.add('d-none');
+ }
+ }
+
+ private getValueFromFormField(field: FormFieldElement): boolean | string {
+ switch (field.tagName.toLowerCase()) {
+ case 'input':
+ assumeType(field);
+
+ if (field.type === 'checkbox') {
+ return field.checked;
+ }
+
+ return field.value;
+
+ case 'select':
+ case 'textarea':
+ return field.value;
+
+ default:
+ throw new Error(`Unsupported tag name: ${field.tagName}`);
+ }
+ }
+
+ private doesMatch(expected: boolean | string, operator: Operator | string, rhs: string) {
+ switch (operator) {
+ case "==":
+ return expected == rhs;
+
+ case "!=":
+ return expected != rhs;
+
+ case "checked":
+ return typeof expected === "boolean" && expected;
+
+ default:
+ throw new Error(`Unsupported operator: ${operator}`);
+ }
+ }
+}
diff --git a/lib/ts/index.ts b/lib/ts/index.ts
index df135fecd8..fd600b1aab 100644
--- a/lib/ts/index.ts
+++ b/lib/ts/index.ts
@@ -1,6 +1,7 @@
import "../css/stacks.less";
import {
BannerController,
+ ConditionalField,
ExpandableController,
ModalController,
PopoverController,
@@ -14,6 +15,7 @@ import { application, StacksApplication } from "./stacks";
// register all built-in controllers
application.register("s-banner", BannerController);
+application.register("s-conditional-field", ConditionalField);
application.register("s-expandable-control", ExpandableController);
application.register("s-modal", ModalController);
application.register("s-toast", ToastController);
diff --git a/lib/ts/utilities/helpers.ts b/lib/ts/utilities/helpers.ts
new file mode 100644
index 0000000000..9e335e0909
--- /dev/null
+++ b/lib/ts/utilities/helpers.ts
@@ -0,0 +1,9 @@
+/**
+ * A no-op function that serves the sole purpose of convincing TypeScript that a
+ * given object is of the specified type.
+ *
+ * @see https://github.com/microsoft/TypeScript/issues/10421
+ */
+export function assumeType(x: unknown): asserts x is T {
+ return;
+}