diff --git a/client/src/controllers/RulesController.stories.js b/client/src/controllers/RulesController.stories.js
new file mode 100644
index 0000000000..c5426706e8
--- /dev/null
+++ b/client/src/controllers/RulesController.stories.js
@@ -0,0 +1,82 @@
+import React from 'react';
+
+import { StimulusWrapper } from '../../storybook/StimulusWrapper';
+import { RulesController } from './RulesController';
+
+export default {
+ title: 'Stimulus / RulesController',
+ argTypes: {
+ debug: { control: 'boolean', defaultValue: false },
+ },
+};
+
+const definitions = [
+ { identifier: 'w-rules', controllerConstructor: RulesController },
+];
+
+const EnableTemplate = ({ debug = false }) => (
+
+
+
+);
+
+export const Enable = EnableTemplate.bind({});
diff --git a/client/src/controllers/RulesController.test.js b/client/src/controllers/RulesController.test.js
new file mode 100644
index 0000000000..dfc2c68697
--- /dev/null
+++ b/client/src/controllers/RulesController.test.js
@@ -0,0 +1,587 @@
+import { Application } from '@hotwired/stimulus';
+import { RulesController } from './RulesController';
+import { escapeHtml } from '../utils/text';
+
+jest.useFakeTimers();
+
+describe('RulesController', () => {
+ const _ = (value) => escapeHtml(JSON.stringify(value));
+
+ let application;
+ let errors = [];
+
+ const setup = async (html) => {
+ document.body.innerHTML = `${html}`;
+
+ application = Application.start();
+
+ application.register('w-rules', RulesController);
+
+ application.handleError = (error, message) => {
+ errors.push({ error, message });
+ };
+
+ await jest.runAllTimersAsync();
+ };
+
+ afterEach(() => {
+ application?.stop();
+ jest.clearAllMocks();
+ errors = [];
+ });
+
+ describe('the ability to parse different data-w-rules attributes', () => {
+ it('should throw an error if the rule is malformed', async () => {
+ expect(errors.length).toBe(0);
+
+ await setup(`
+
+ `);
+
+ expect(
+ Array.from(document.querySelectorAll('[data-w-rules-target]')).every(
+ (target) => !target.disabled,
+ ),
+ ).toBe(true);
+
+ expect(errors.length).toBeGreaterThan(1);
+
+ expect(errors).toHaveProperty(
+ '0.error.message',
+ expect.stringContaining("Expected property name or '}' in JSON"),
+ );
+ });
+
+ it('should gracefully handle different empty structures', async () => {
+ await setup(`
+ `);
+
+ document
+ .querySelector('form')
+ .dispatchEvent(new Event('change', { bubbles: true }));
+
+ const handleEffect = jest.fn();
+ document.addEventListener('w-rules:effect', handleEffect);
+
+ expect(handleEffect).toHaveBeenCalledTimes(0);
+
+ await jest.runAllTimersAsync();
+
+ // check that no errors were thrown & no changes made due to empty-ish rules
+
+ expect(
+ [...document.querySelectorAll('input')].every(
+ (input) => input.disabled,
+ ),
+ ).toBe(false);
+
+ expect(handleEffect).toHaveBeenCalledTimes(0);
+ });
+
+ it('should support an entries style array of key/value pairs to be used as an object', async () => {
+ await setup(`
+
+ `);
+
+ expect(document.getElementById('signature').disabled).toBe(true);
+
+ document.getElementById('agreement').checked = false;
+ document
+ .getElementById('agreement')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(document.getElementById('signature').disabled).toBe(false);
+ });
+
+ it('should support checkboxes with an empty value', async () => {
+ await setup(`
+
+ `);
+
+ expect(document.getElementById('enable-if-unchecked').disabled).toBe(
+ false,
+ );
+
+ expect(document.getElementById('enable-if-checked').disabled).toBe(true);
+
+ // check the agreement checkbox
+ document.getElementById('agreement').checked = true;
+ document
+ .getElementById('agreement')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(document.getElementById('enable-if-unchecked').disabled).toBe(
+ true,
+ );
+ expect(document.getElementById('enable-if-checked').disabled).toBe(false);
+ });
+
+ it('should treat false/null as their string equivalents', async () => {
+ // Note: We may revisit this in the future, for now we want to confirm all values are resolved to strings for consistency.
+
+ await setup(`
+
+ `);
+
+ // set to disabled when initializing based on rules
+ expect(
+ Array.from(document.querySelectorAll('.test')).map(
+ (target) => target.disabled,
+ ),
+ ).toEqual([false, false]);
+
+ document.getElementById('confirm').checked = false;
+ document
+ .getElementById('confirm')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ document.getElementById('signUp').value = '';
+ document
+ .getElementById('confirm')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(
+ Array.from(document.querySelectorAll('.test')).map(
+ (target) => target.disabled,
+ ),
+ ).toEqual([true, true]);
+ });
+
+ it('should support multiple values for the same field name correctly', async () => {
+ await setup(`
+
+ `);
+
+ // set to disabled when initializing based on rules
+ expect(document.getElementById('continue').disabled).toBe(true);
+
+ // 1 - Check rule against a value that is not in the enable rules
+
+ document.getElementById('NCC-1701').checked = true;
+ document
+ .getElementById('NCC-1701')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ document.getElementById('NCC-71201').checked = true;
+ document
+ .getElementById('NCC-71201')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(document.getElementById('continue').disabled).toBe(true);
+
+ // 2 - Check rule against values that are in the the enable rules
+
+ document.getElementById('NCC-1701').checked = false;
+ document
+ .getElementById('NCC-1701')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ document.getElementById('NCC-1701-D').checked = true;
+ document
+ .getElementById('NCC-1701-D')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ document.getElementById('NCC-1701-E').checked = true;
+ document
+ .getElementById('NCC-1701-E')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(document.getElementById('continue').disabled).toBe(false);
+ });
+ });
+
+ describe('the ability to find the most suitable form for formData', () => {
+ it('should resolve the form from the controlled element input if the form is not the controlled element', async () => {
+ await setup(`
+ `);
+
+ const input = document.querySelector('input[name="title"]');
+ const enableInput = document.querySelector('input[name="enter"]');
+
+ expect(enableInput.disabled).toBeTruthy();
+
+ input.value = 'good';
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(enableInput.disabled).toBeFalsy();
+ });
+
+ it('should resolve the form from the controlled element closest form if it cannot find it on the controlled element', async () => {
+ await setup(`
+ `);
+
+ const input = document.querySelector('input[name="title"]');
+ const enableInput = document.querySelector('input[name="enter"]');
+
+ expect(enableInput.disabled).toBeTruthy();
+
+ input.value = 'good';
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(enableInput.disabled).toBeFalsy();
+ });
+ });
+
+ describe('the ability for the controller to avoid unnecessary resolving', () => {
+ it('should not check for the form data if there are no targets', async () => {
+ const handleResolved = jest.fn();
+
+ document.addEventListener('w-rules:resolved', handleResolved);
+
+ await setup(`
+ `);
+
+ const noteField = document.getElementById('note');
+
+ expect(
+ document.querySelector('form').getAttribute('data-controller'),
+ ).toBeTruthy();
+
+ expect(handleResolved).not.toHaveBeenCalled();
+
+ document
+ .querySelector('input')
+ .dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(handleResolved).not.toHaveBeenCalled();
+
+ // add a target & trigger a change event
+
+ noteField.setAttribute('data-w-rules-target', 'enable');
+ await jest.runAllTimersAsync();
+
+ expect(handleResolved).toHaveBeenCalledTimes(1);
+
+ noteField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(handleResolved).toHaveBeenCalledTimes(2);
+
+ // now remove the target and check that the event no longer fires
+
+ noteField.remove();
+
+ document
+ .querySelector('input')
+ .dispatchEvent(new Event('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(handleResolved).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('conditionally enabling a target', () => {
+ it('should provide a way to conditionally enable a target and dispatch events', async () => {
+ const handleResolved = jest.fn();
+ const handleEffect = jest.fn();
+
+ document.addEventListener('w-rules:effect', handleEffect);
+ document.addEventListener('w-rules:resolved', handleResolved);
+
+ await setup(`
+ `);
+
+ const checkbox = document.querySelector('#agreement-field');
+ const button = document.querySelector('[data-w-rules-target="enable"]');
+
+ expect(checkbox.checked).toBe(false);
+ expect(button.disabled).toBe(true);
+ expect(handleResolved).toHaveBeenCalledTimes(1);
+ expect(handleEffect).toHaveBeenCalledTimes(0); // no changes actually made to elements
+
+ checkbox.click();
+ checkbox.dispatchEvent(
+ new Event('change', { bubbles: true, cancelable: false }),
+ );
+ await jest.runAllTimersAsync();
+
+ expect(checkbox.checked).toBe(true);
+ expect(button.disabled).toBe(false);
+
+ checkbox.click();
+ checkbox.dispatchEvent(
+ new Event('change', { bubbles: true, cancelable: false }),
+ );
+ await jest.runAllTimersAsync();
+
+ expect(checkbox.checked).toBe(false);
+ expect(button.disabled).toBe(true);
+ expect(handleResolved).toHaveBeenCalledTimes(3); // rules are resolved two additional times
+ expect(handleEffect).toHaveBeenCalledTimes(2); // two changes made to elements
+ expect(handleEffect.mock.calls[0][0]).toEqual(
+ expect.objectContaining({
+ target: document.getElementById('continue'),
+ detail: {
+ effect: 'enable',
+ enable: true,
+ },
+ }),
+ );
+ expect(handleEffect.mock.calls[1][0]).toEqual(
+ expect.objectContaining({
+ target: document.getElementById('continue'),
+ detail: {
+ effect: 'enable',
+ enable: false,
+ },
+ }),
+ );
+ });
+
+ it('should ensure that the enabled/disabled attributes sync once connected', async () => {
+ await setup(`
+ `);
+
+ expect(document.getElementById('my-device-check').disabled).toBe(true);
+ });
+
+ it('should support conditional enabling of sets of fields based on a select field', async () => {
+ await setup(`
+
+ `);
+
+ const filterField = document.getElementById('id_filter_method');
+ const widthField = document.getElementById('id_width');
+ const heightField = document.getElementById('id_height');
+ const closenessField = document.getElementById('id_closeness');
+
+ expect(filterField.value).toEqual('original');
+
+ // should all be disabled at the start (original selected)
+
+ expect(widthField.disabled).toBe(true);
+ expect(heightField.disabled).toBe(true);
+ expect(closenessField.disabled).toBe(true);
+
+ // now change the filter to width
+
+ filterField.value = 'width';
+
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(widthField.disabled).toBe(false);
+ expect(heightField.disabled).toBe(true);
+ expect(closenessField.disabled).toBe(true);
+
+ // now change the filter to height
+
+ filterField.value = 'height';
+
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(widthField.disabled).toBe(true);
+ expect(heightField.disabled).toBe(false);
+ expect(closenessField.disabled).toBe(true);
+
+ // now change the filter to max
+
+ filterField.value = 'max';
+
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(widthField.disabled).toBe(false);
+ expect(heightField.disabled).toBe(false);
+ expect(closenessField.disabled).toBe(true);
+
+ // now change the filter to fill
+
+ filterField.value = 'fill';
+
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(widthField.disabled).toBe(false);
+ expect(heightField.disabled).toBe(false);
+ expect(closenessField.disabled).toBe(false);
+
+ // set back to original
+
+ filterField.value = 'original';
+
+ filterField.dispatchEvent(new Event('change', { bubbles: true }));
+ await jest.runAllTimersAsync();
+
+ expect(filterField.value).toEqual('original');
+ expect(widthField.disabled).toBe(true);
+ expect(heightField.disabled).toBe(true);
+ expect(closenessField.disabled).toBe(true);
+ });
+ });
+});
diff --git a/client/src/controllers/RulesController.ts b/client/src/controllers/RulesController.ts
new file mode 100644
index 0000000000..ee1091882d
--- /dev/null
+++ b/client/src/controllers/RulesController.ts
@@ -0,0 +1,152 @@
+/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["disabled"] }] */
+
+import { Controller } from '@hotwired/stimulus';
+
+import { castArray } from '../utils/castArray';
+import { debounce } from '../utils/debounce';
+
+/**
+ * Form control elements that can support the `disabled` attribute.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled
+ */
+type FormControlElement =
+ | HTMLButtonElement
+ | HTMLFieldSetElement
+ | HTMLInputElement
+ | HTMLOptGroupElement
+ | HTMLOptionElement
+ | HTMLSelectElement
+ | HTMLTextAreaElement;
+
+/**
+ * Adds the ability for a controlled form element to conditionally
+ * enable targeted elements based on the data from the controlled form
+ * along with a set of rules to match against that data.
+ *
+ * @example - Enable a button if a specific value is chosen
+ * ```html
+ *
+ * ```
+ */
+export class RulesController extends Controller<
+ HTMLFormElement | FormControlElement
+> {
+ static targets = ['enable'];
+
+ /** Targets will be enabled if the target's rule matches the scoped form data, otherwise will be disabled. */
+ declare readonly enableTargets: FormControlElement[];
+ /** True if there is at least one enable target, used to ensure rules do not run if not needed. */
+ declare readonly hasEnableTarget: boolean;
+
+ declare form;
+ declare rulesCache: Record;
+
+ initialize() {
+ this.rulesCache = {};
+ this.resolve = debounce(this.resolve.bind(this), 50);
+
+ const element = this.element;
+ if (element instanceof HTMLFormElement) {
+ this.form = element;
+ } else if ('form' in element) {
+ this.form = element.form;
+ } else {
+ this.form = element.closest('form');
+ }
+ }
+
+ /**
+ * Resolve the conditional targets based on the form data and the target(s)
+ * rule attributes and the controlled element's form data.
+ */
+ resolve() {
+ if (!this.hasEnableTarget) return;
+
+ const formData = new FormData(this.form);
+
+ this.enableTargets.forEach((target) => {
+ const rules = this.parseRules(target);
+
+ const enable = rules.every(([fieldName, allowedValues]) => {
+ // Forms can have multiple values for the same field name
+ const values = formData.getAll(fieldName);
+ // Checkbox fields will NOT appear in FormData unless checked, support this when validValues are also empty
+ if (allowedValues.length === 0 && values.length === 0) return true;
+ return allowedValues.some((validValue) => values.includes(validValue));
+ });
+
+ if (enable === !target.disabled) return;
+
+ const event = this.dispatch('effect', {
+ bubbles: true,
+ cancelable: true,
+ detail: { effect: 'enable', enable },
+ target,
+ });
+
+ if (!event.defaultPrevented) {
+ target.disabled = !enable;
+ }
+ });
+
+ this.dispatch('resolved', { bubbles: true, cancelable: false });
+ }
+
+ enableTargetDisconnected() {
+ this.resolve();
+ }
+
+ enableTargetConnected() {
+ this.resolve();
+ }
+
+ /**
+ * Finds & parses the rules for the provided target by the rules attribute,
+ * which is determined via the identifier (e.g. `data-w-rules`).
+ * Check the rules cache first, then parse the rules for caching if not found.
+ *
+ * When parsing the rule, assume an `Object.entries` format or convert an
+ * object to this format. Then ensure each value is an array of strings
+ * for consistent comparison to FormData values.
+ */
+ parseRules(target: Element) {
+ if (!target) return [];
+ const rulesRaw = target.getAttribute(`data-${this.identifier}`);
+ if (!rulesRaw) return [];
+
+ const cachedRule = this.rulesCache[rulesRaw];
+ if (cachedRule) return cachedRule;
+
+ let parsedRules;
+
+ try {
+ parsedRules = JSON.parse(rulesRaw);
+ } catch (error) {
+ this.context.handleError(error, 'Unable to parse rule.');
+ return [];
+ }
+
+ const rules = (
+ Array.isArray(parsedRules) ? parsedRules : Object.entries(parsedRules)
+ )
+ .filter(Array.isArray)
+ .map(([fieldName = '', validValues = ''] = []) => [
+ fieldName,
+ castArray(validValues).map(String),
+ ]) as [string, string[]][];
+
+ this.rulesCache[rulesRaw] = rules;
+
+ return rules;
+ }
+}
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index 4536a173d4..792ff69e9a 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -18,6 +18,7 @@ import { OrderableController } from './OrderableController';
import { PreviewController } from './PreviewController';
import { ProgressController } from './ProgressController';
import { RevealController } from './RevealController';
+import { RulesController } from './RulesController';
import { SessionController } from './SessionController';
import { SkipLinkController } from './SkipLinkController';
import { SlugController } from './SlugController';
@@ -55,6 +56,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: ProgressController, identifier: 'w-progress' },
{ controllerConstructor: RevealController, identifier: 'w-breadcrumbs' },
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
+ { controllerConstructor: RulesController, identifier: 'w-rules' },
{ controllerConstructor: SessionController, identifier: 'w-session' },
{ controllerConstructor: SkipLinkController, identifier: 'w-skip-link' },
{ controllerConstructor: SlugController, identifier: 'w-slug' },