Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new "Conditional Field" Stimulus controller #1248

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/_data/site-navigation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
87 changes: 87 additions & 0 deletions docs/product/components/conditional-fields.html
Original file line number Diff line number Diff line change
@@ -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.
---

<section class="stacks-section">
{% tip "mb24" %}
<strong>Accessibility Consideration:</strong> 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 <a href="https://accessibility.blog.gov.uk/2021/09/21/an-update-on-the-accessibility-of-conditionally-revealed-questions/">Gov.UK's article on conditionally revealed questions</a> for more information.
{% endtip %}

{% header "h2", "Radio Buttons" %}
{% capture example_one %}
<fieldset class="s-check-group g8">
<legend class="s-label">Support topic I'd like to ask about</legend>
<div class="s-check-control">
<input type="radio" name="support-topic" id="account-issue" value="account-issue">
<label for="account-issue" class="s-label">Account Issue</label>
</div>

<fieldset
class="d-none s-check-group g8 pl24"
data-controller="s-conditional-field"
data-s-conditional-field-target-id-value="account-issue"
data-s-conditional-field-operator-value="=="
data-s-conditional-field-expected-value="account-issue"
>
<legend class="v-visible-sr">Account action I want to perform</legend>

<div class="s-check-control">
<input type="radio" name="account-issue" id="lost-password" value="lost-password">
<label for="lost-password" class="s-label">I lost my password</label>
</div>
<div class="s-check-control">
<input type="radio" name="account-issue" id="add-remove-creds" value="add-remove-credentials">
<label for="add-remove-creds" class="s-label">I need to add or remove login credentials</label>
</div>
<div class="s-check-control">
<input type="radio" name="account-issue" id="delete-user-profile" value="delete-user-profile">
<label for="delete-user-profile" class="s-label">I need to delete my user profile</label>
</div>
<div class="s-check-control">
<input type="radio" name="account-issue" id="merge-user-profiles" value="merge-user-profiles">
<label for="merge-user-profiles" class="s-label">I need to merge user profiles</label>
</div>

<div
class="d-none"
data-controller="s-conditional-field"
data-s-conditional-field-target-id-value="merge-user-profiles"
data-s-conditional-field-operator-value="=="
data-s-conditional-field-expected-value="merge-user-profiles"
>
<div class="d-flex fd-column gy4 pl24">
<div class="flex--item">
<label for="account-id" class="s-label">Account ID</label>
</div>
<div class="flex--item">
<input type="number" class="s-input" id="account-id">
</div>
</div>
</div>
</fieldset>

<div class="s-check-control">
<input type="radio" name="support-topic" id="stack-exchange-qa-issue" value="stack-exchange-qa-issue">
<label for="stack-exchange-qa-issue" class="s-label">Stack Exchange Q&A Issue</label>
</div>
<div class="s-check-control">
<input type="radio" name="support-topic" id="trust-and-safety" value="trust-and-safety">
<label for="trust-and-safety" class="s-label">Trust and Safety</label>
</div>
<div class="s-check-control">
<input type="radio" name="support-topic" id="other-topic" value="other-topic">
<label for="other-topic" class="s-label">Other</label>
</div>
</fieldset>
{% endcapture %}

<div class="stacks-preview">
{{ example_one | highlight: "html" }}
<div class="stacks-preview--example">
{{ example_one }}
</div>
</div>
</section>
1 change: 1 addition & 0 deletions lib/ts/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
118 changes: 118 additions & 0 deletions lib/ts/controllers/s-conditional-field.ts
Original file line number Diff line number Diff line change
@@ -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<FormFieldElement>(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<FormFieldElement>(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<HTMLInputElement>(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}`);
}
}
}
2 changes: 2 additions & 0 deletions lib/ts/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "../css/stacks.less";
import {
BannerController,
ConditionalField,
ExpandableController,
ModalController,
PopoverController,
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions lib/ts/utilities/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<T>(x: unknown): asserts x is T {
return;
}