In this post, we’ll look into the Material Button Toggle. In our example below, we develop a form asking for demographic information and test that functionality.
What do we use button toggles for? On Google’s Material site it describes the button toggle as
Toggle buttons can be used to group related options. To emphasize groups of related toggle buttons, a group should share a common container.
https://material.io/components/buttons/#toggle-button
And from a usage perspective, Google says
https://material.angular.io/components/button-toggle/overview
<mat-button-toggle>
are on/off toggles with the appearance of a button. These toggles can be configured to behave as either radio-buttons or checkboxes. While they can be standalone, they are typically part of amat-button-toggle-group
.
As it states above, button toggles give us a way to add groups of selections, using a component that looks like a button, but behaves like a checkbox or radio button. Button toggles can use either text or icons as the indicator. a Potential use of button toggles would include demographic selections, such as age, gender, race or ethnicity.
The Code
Let’s start looking at our code from the front end, in our HTML file. One thing of note, all of the button toggles we use here are part of a mat-button-toggle-group, as suggested by Google. I’ve added those mat-button-toggle-groups to a larger form. We’ll focus on our interesting parts below. This is the ageSelection group, which triggers a legalGuardian group or speakToParents group depending on the selection.
First, we’ll make both styling and testing easier by giving our toggle group an ID. We then bind the group to our form, using the form control name ageGroup. This matches to our formGroup instantiated in our component.
this.medForm = new FormGroup({ ageGroup: new FormControl(''), legalGuardian: new FormControl(''), speakToParents: new FormControl(''), originCountry: new FormControl(''), });
When a change is detected, that is we click one of the button toggles, we fire the (change) EventEmitter which calls an ageCheck() method. We will dive into that code later. Finally, we have our group of mat-button-toggles that are simple buttons that represent age groups. Here is what the mat-button-toggle-group looks like.
<mat-button-toggle-group id="ageSelection" #group="matButtonToggleGroup" formControlName="ageGroup" (change)="ageCheck()" style=" margin-bottom: 0.5rem;" > <mat-button-toggle value="1" aria-label="Text align left"> <span>1-12</span> </mat-button-toggle> <mat-button-toggle value="13" aria-label="Text align center"> <span>13-18</span> </mat-button-toggle> <mat-button-toggle value="19" aria-label="Text align center"> <span>19-25</span> </mat-button-toggle> <mat-button-toggle value="26" aria-label="Text align center"> <span>26-35</span> </mat-button-toggle> <mat-button-toggle value="36" aria-label="Text align center"> <span>36+</span> </mat-button-toggle> </mat-button-toggle-group>
Next, we have one of our toggle groups that appears based on which selection is made in the above group. The under13 property in our *ngIf is set through the ageCheck() method. When set, the div that contains our legalGuardian group is shown. Like above, we bind the legalGuardian control to the form using the formControlName directive, and give our toggle group an ID for testing and styling help.
<div *ngIf="under13"> <label for="legalGuardian" style="margin-right: 0.5rem;" >Are you the Legal Guardian/Parent?</label> <mat-button-toggle-group id="legalGuardian" style="margin-bottom: 0.5rem" formControlName="legalGuardian"> <mat-button-toggle value="1"><span>Yes</span></mat-button-toggle> <mat-button-toggle value="0"><span>No</span></mat-button-toggle> </mat-button-toggle-group> <br /> </div>
Here is our full HTML file.
<form [formGroup]="medForm" (submit)="onSubmit()" id="medForm"> <label for="ageSelection" style="margin-right: 0.5rem;">Age Group</label> <mat-button-toggle-group id="ageSelection" #group="matButtonToggleGroup" formControlName="ageGroup" (change)="ageCheck()" style=" margin-bottom: 0.5rem;"> <mat-button-toggle value="1" aria-label="Text align left"> <span>1-12</span> </mat-button-toggle> <mat-button-toggle value="13" aria-label="Text align center"> <span>13-18</span> </mat-button-toggle> <mat-button-toggle value="19" aria-label="Text align center"> <span>19-25</span> </mat-button-toggle> <mat-button-toggle value="26" aria-label="Text align center"> <span>26-35</span> </mat-button-toggle> <mat-button-toggle value="36" aria-label="Text align center"> <span>36+</span> </mat-button-toggle> </mat-button-toggle-group> <br /> <div *ngIf="under13"> <label for="legalGuardian" style="margin-right: 0.5rem;" >Are you the Legal Guardian/Parent?</label> <mat-button-toggle-group id="legalGuardian" style="margin-bottom: 0.5rem" formControlName="legalGuardian"> <mat-button-toggle value="1"><span>Yes</span></mat-button-toggle> <mat-button-toggle value="0"><span>No</span></mat-button-toggle> </mat-button-toggle-group> <br /> </div> <div *ngIf="speakToParent"> <label for="speakToParents" style="margin-right: 0.5rem;" >Can we speak to your parents about this visit?</label> <mat-button-toggle-group id="speakToParents" style="margin-bottom: 0.5rem" formControlName="speakToParents" > <mat-button-toggle value="1"><span>Yes</span>></mat-button-toggle> <mat-button-toggle value="0"><span>No</span></mat-button-toggle> </mat-button-toggle-group> <br /> </div> <label for="originSelection" style="margin-right: 0.5rem;">Nationality</label> <mat-button-toggle-group id="originSelection" #group="matButtonToggleGroup" multiple formControlName="originCountry" style=" margin-bottom: 0.5rem;" > <mat-button-toggle value="white" aria-label="Text align left"> <span>White</span> </mat-button-toggle> <mat-button-toggle value="black" aria-label="Text align center"> <span>Black</span> </mat-button-toggle> <mat-button-toggle value="native" aria-label="Text align center"> <span>Native American</span> </mat-button-toggle> <mat-button-toggle value="hispanic" aria-label="Text align center"> <span>Hispanic</span> </mat-button-toggle> <mat-button-toggle value="other" aria-label="Text align center"> <span>Other</span> </mat-button-toggle> </mat-button-toggle-group> <br /> <button mat-button id="submitButton">Submit</button> </form>
Our component.ts file is fairly standard and a bit sparce for what it affords us. First, we declare our properties, including the under13 and speakToParent properties, which will help us show and hide two of our elements in the HTML file. Next, we declare our FormGroup and the controls that live in that form in the constructor. The way we’ve handled showing and hiding our two optional elements, is to start by ensuring those controls are removed when the component is initialized, in the ngOnInit() method. We do this to avoid having the data from those controls pushed to our handling functionality. It wouldn’t make sense to have the legalGuardian and speakToParents items filled, even if with a False, for say, a 40 year old. It suggests that they were asked the questions, which they were not. We’ll see in a moment how we add those back in.
ngOnInit() { this.medForm.removeControl('legalGuardian'); this.medForm.removeControl('speakToParents'); }
Our onSubmit handler is simple, it sets the value of a property and resets the form. In a real world example we might send this right to a database, or to a service that performs more processing on the data.
onSubmit() { this.submitResult = this.medForm.value; this.medForm.reset(); }
Lastly is the most interesting method in this example, the ageCheck(). In our age check we grab the value of our ageGroup control and store it in a variable named ageSelection. At this point, it’s stored as a string. In order to easily perform the equality checks we need, we use the parseInt() method to convert it to a base 10 number. Depending on the value in ageSelection, we then check if the competing element is already on the screen. The idea here is if the person entering data on the form first selected, for example, the 13 value and then changed to the 1 value, the element related to the 13 value would be showing in addition to element related to the 1 element. This is not appropriate so we check for that and remove the incorrect element first. We then add the proper control to the screen using the addControl() method in our form.
if (ageSelection === 1) { if (this.medForm.contains('speakToParents')) { this.medForm.removeControl('speakToParents'); } this.medForm.addControl('legalGuardian', new FormControl(''));
We also set the instance variables that trigger showing these elements properly. In the case of a selection of the first button in the group with the value of 1, we remove the speakToParents element if it is shown, add the legalGuardian element, set this.under13 to true and this.speakToParent to false. The last bit is to hide the wrong control, and show the correct control. And here is the full ageCheck() method.
ageCheck() { const ageSelection = parseInt(this.medForm.controls.ageGroup.value, 10); // we would do this a bit differently in the "real world" if (ageSelection === 1) { if (this.medForm.contains('speakToParents')) { this.medForm.removeControl('speakToParents'); } this.medForm.addControl('legalGuardian', new FormControl('')); this.under13 = true; this.speakToParent = false; } else if (ageSelection === 13) { if (this.medForm.contains('legalGuardian')) { this.medForm.removeControl('legalGuardian'); } this.medForm.addControl('speakToParents', new FormControl('')); this.speakToParent = true; this.under13 = false; } else { this.under13 = false; this.speakToParent = false; } }
With that, our functionality is in place to get information from a user, and trigger the addition or removal of form elements. Specifically our button toggle groups.
Here is our full component.ts file.
import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; @Component({ selector: 'app-button-toggle-ut', templateUrl: './button-toggle-ut.component.html', styleUrls: ['./button-toggle-ut.component.css'] }) export class ButtonToggleUtComponent implements OnInit { medForm: FormGroup; under13 = false; speakToParent = false; submitResult; constructor() { this.medForm = new FormGroup({ ageGroup: new FormControl(''), legalGuardian: new FormControl(''), speakToParents: new FormControl(''), originCountry: new FormControl(''), }); } ngOnInit() { this.medForm.removeControl('legalGuardian'); this.medForm.removeControl('speakToParents'); } onSubmit() { // console.log('form submitted ', this.medForm.value); this.submitResult = this.medForm.value; this.medForm.reset(); } ageCheck() { const ageSelection = parseInt(this.medForm.controls.ageGroup.value, 10); // we would do this a bit differently in the "real world" if (ageSelection === 1) { if (this.medForm.contains('speakToParents')) { this.medForm.removeControl('speakToParents'); } this.medForm.addControl('legalGuardian', new FormControl('')); this.under13 = true; this.speakToParent = false; } else if (ageSelection === 13) { if (this.medForm.contains('legalGuardian')) { this.medForm.removeControl('legalGuardian'); } this.medForm.addControl('speakToParents', new FormControl('')); this.speakToParent = true; this.under13 = false; } else { this.under13 = false; this.speakToParent = false; } } }
The Tests
Our tests here are long. Most of the content of these tests do the “same old thing”, so should be broken out into helper classes. For our exercise here, I keep the IT clauses long and tied into the spec to show the flow of how testing could work. We’ll break the spec apart and take a look at bits and pieces of the test, and have the full test to review the flow at the end.
Our setup is fairly common. We import the requisite libraries, setup the expected data from our test cases, and configure our TestBed and fixture. After that we jump right in to testing. We start with our age selection. This element is important to test, as it triggers other elements to show. First, we grab our toggle group using the element name and id. Again, this is one of the great uses of an id, to find our element under test, particularly when we have multiple elements of the same type like we do here. We then convert the result of that to an Array. This makes iterating and grabbing our enclosed elements easier.
const ageGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#ageSelection' ); const ageButtonToggles = Array.from( ageGroupToggle.getElementsByTagName('mat-button-toggle') );
Now that we’re a few levels deeper, and we have an array, we can grab the specific toggle under test by filtering our ageButtonToggles array. We use the ageGroup property from our expected test result to find the buttonToggle. This is a neat use of the conversion of an HTMLElement[] to an Element Array.
const ageToggleUnderTest = ageButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.ageGroup );
Here’s the secret sauce of our toggle button, the button hidden down in the mat-button-toggle element. We grab that button from the return value of our filter by looking for the first element, ageToggleUnderTest[0].
const ageToggleButton = ageToggleUnderTest[0].querySelector('button');
And finally we trigger a click MouseEvent to select that element. At this point, we have our ageGroup selection completed and depending on what group is chosen, we might have other elements that showed up on our page. Here is the full age toggle section.
const ageGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#ageSelection' ); const ageButtonToggles = Array.from( ageGroupToggle.getElementsByTagName('mat-button-toggle') ); const ageToggleUnderTest = ageButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.ageGroup ); const ageToggleButton = ageToggleUnderTest[0].querySelector('button'); ageToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges();
Part of our test has to be whether the guardianGroupToggle and/or speakToParentsToggle shows up. In the case of the selection and test above, we should see the guardianGroup and not see the speakToParents group, so let’s test for that.
Since the guardianGroupToggle, which is the element we expect, looks just like the ageGroupToggle above, we go about finding the element and the selection we want to make in the same way. We grab our mat-button-toggle-group for legalGuardian, coax it into an Array, filter to find our targeted selection, grab the button and finally press it.
const guardianGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#legalGuardian' ); const guardianButtonToggles = Array.from( guardianGroupToggle.getElementsByTagName('mat-button-toggle') ); const guardianToggleUnderTest = guardianButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.legalGuardian ); const guardianToggleButton = guardianToggleUnderTest[0].querySelector( 'button' ); guardianToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges();
To be sure our speakToParents element does not show up, we also look for that mat-button-toggle-group and verify that the result is falsy, or that it doesn’t exist.
const speakToParentsGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#speakToParents' ); expect(speakToParentsGroupToggle).toBeFalsy();
From a test perspective, there’s not much else to say. We perform the same work to get our last toggle button, then submit the form and ensure that what we submitted is what we expected. And we work through this same testing strategy for the other expected outcomes, one for a child, one for an adolescent, and one for an adult. There are a lot of optimizations we could do to this test. For one, as I speak to above, we can move most of the functionality used in these tests to helper methods so we’re not reinventing the wheel in each it clause. Again, I left this long to show the flow of each test.
Here is the full spec.
import { async, ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatChipsModule, MatChip } from '@angular/material/chips'; import { MatButtonModule } from '@angular/material/button'; import { ButtonToggleUtComponent } from './button-toggle-ut.component'; describe('ButtonToggleUtComponent', () => { let component: ButtonToggleUtComponent; let fixture: ComponentFixture<ButtonToggleUtComponent>; const adultTestData = { ageGroup: '19', originCountry: ['hispanic', 'black'] }; const childTestData = { ageGroup: '1', legalGuardian: '1', originCountry: ['white'] }; const adolescentData = { ageGroup: '13', speakToParents: '0', originCountry: ['native'] }; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ButtonToggleUtComponent], imports: [ FormsModule, ReactiveFormsModule, MatButtonToggleModule, MatChipsModule, MatButtonModule ] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ButtonToggleUtComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should submit data for a child when submit pressed', () => { const ageGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#ageSelection' ); const ageButtonToggles = Array.from( ageGroupToggle.getElementsByTagName('mat-button-toggle') ); const ageToggleUnderTest = ageButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.ageGroup ); const ageToggleButton = ageToggleUnderTest[0].querySelector('button'); ageToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); const guardianGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#legalGuardian' ); const guardianButtonToggles = Array.from( guardianGroupToggle.getElementsByTagName('mat-button-toggle') ); const guardianToggleUnderTest = guardianButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.legalGuardian ); const guardianToggleButton = guardianToggleUnderTest[0].querySelector( 'button' ); guardianToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); const speakToParentsGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#speakToParents' ); expect(speakToParentsGroupToggle).toBeFalsy(); const originGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#originSelection' ); const originButtonToggles = Array.from( originGroupToggle.getElementsByTagName('mat-button-toggle') ); const originToggleUnderTest = originButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === childTestData.originCountry[0] ); const originToggleButton = originToggleUnderTest[0].querySelector('button'); originToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); const button = fixture.debugElement.nativeElement.querySelector( 'button#submitButton' ); button.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); expect(component.submitResult).toEqual(childTestData); }); it('should submit data for a adolescent when submit pressed', () => { // select our age const ageGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#ageSelection' ); const ageButtonToggles = Array.from( ageGroupToggle.getElementsByTagName('mat-button-toggle') ); // now we're an array, so we can use methods like filter to get the // toggle we're interested in const ageToggleUnderTest = ageButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === adolescentData.ageGroup ); // we have a button instide the mat-button-toggle element, so let's grab one layer deeper const ageToggleButton = ageToggleUnderTest[0].querySelector('button'); ageToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); // since it's a teen person, let's find out if they want us to // talk to a parent/guardian const speakToParentToggleGroup: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#speakToParents' ); const speakToParentToggles = Array.from( speakToParentToggleGroup.getElementsByTagName('mat-button-toggle') ); const speakToParentToggleUnderTest = speakToParentToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === adolescentData.speakToParents ); const speakToParentsToggleButton = speakToParentToggleUnderTest[0].querySelector( 'button' ); speakToParentsToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); // guardian doesn't show up const guardianToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#legalGuardian' ); // shouldn't exist expect(guardianToggle).toBeFalsy(); // select our origin const originGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#originSelection' ); const originButtonToggles = Array.from( originGroupToggle.getElementsByTagName('mat-button-toggle') ); const originToggleUnderTest = originButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === adolescentData.originCountry[0] ); const originToggleButton = originToggleUnderTest[0].querySelector('button'); originToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); // Submit that baby! const button = fixture.debugElement.nativeElement.querySelector( 'button#submitButton' ); button.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); expect(component.submitResult).toEqual(adolescentData); }); it('should submit data for an adult when submit pressed', () => { // select our age const ageGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#ageSelection' ); const ageButtonToggles = Array.from( ageGroupToggle.getElementsByTagName('mat-button-toggle') ); // now we're an array, so we can use methods like filter to get the // toggle we're interested in const ageToggleUnderTest = ageButtonToggles.filter( buttonToggle => buttonToggle.getAttribute('value') === adultTestData.ageGroup ); // we have a button instide the mat-button-toggle element, so let's grab one layer deeper const ageToggleButton = ageToggleUnderTest[0].querySelector('button'); ageToggleButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); // No extra toggles const speakToParentToggleGroup: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#speakToParents' ); // shouldn't exist expect(speakToParentToggleGroup).toBeFalsy(); // guardian doesn't show up const guardianToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#legalGuardian' ); // shouldn't exist expect(guardianToggle).toBeFalsy(); // select our origin const originGroupToggle: HTMLElement = fixture.debugElement.nativeElement.querySelector( 'mat-button-toggle-group#originSelection' ); const originButtonToggles = Array.from( originGroupToggle.getElementsByTagName('mat-button-toggle') ); const originTogglesUnderTest = originButtonToggles.filter( buttonToggle => adultTestData.originCountry.indexOf( buttonToggle.getAttribute('value') ) > -1 ); for (const buttonToggle of originTogglesUnderTest) { const thisButton = buttonToggle.querySelector('button'); thisButton.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); } // Submit that baby! const button = fixture.debugElement.nativeElement.querySelector( 'button#submitButton' ); button.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); expect(component.submitResult).toEqual(jasmine.objectContaining({ ageGroup: adultTestData.ageGroup, originCountry: jasmine.arrayContaining(adultTestData.originCountry) })); }); });
Wrapup
Material Button Toggles are great for triggering an action that has several potential inputs that are correlated. Button toggles are a more powerful option than Radio Buttons or CheckBoxes in that they can appear as icons, and work like buttons. They have a super power compared to buttons in how they appear as a cohesive group, and act in that way as well.
We were only concerned with testing the basics of the Button Toggle here, though we did dive into adding and removing other Button Toggle Groups based on the selection of a specific value in another toggle group. We also did not use the icon functionality, which you might have seen, for example, as text justification boxes. Like other elements, adding in testing of more complex content is made easier once you know how to test the basic functionality.
Always remember, our goal in testing shouldn’t be individual element based it should be looking for outcomes of a workflow. Testing a user expectation. So the tests outlined above should be part of a larger “should” and shouldn’t be the ends itself.
All of the code shown here, and more Angular tests, can be found in this GitHub repo.