diff --git a/commitlint.config.js b/commitlint.config.js
index d7ee19a1f..08a18fb83 100644
--- a/commitlint.config.js
+++ b/commitlint.config.js
@@ -9,6 +9,7 @@ module.exports = {
'badge',
'build',
'button',
+ 'button-toggle',
'ci',
'cdk',
'card',
diff --git a/package.json b/package.json
index 578dde3f0..612de3d34 100644
--- a/package.json
+++ b/package.json
@@ -157,6 +157,7 @@
"server-dev:alert": "npm run server-dev -- --env.component alert",
"server-dev:badge": "npm run server-dev -- --env.component badge",
"server-dev:button": "npm run server-dev -- --env.component button",
+ "server-dev:button-toggle": "npm run server-dev -- --env.component button-toggle",
"server-dev:card": "npm run server-dev -- --env.component card",
"server-dev:cdk-vscroll-custom-strategy": "npm run server-dev -- --env.component cdk-virtual-scroll-custom-strategy",
"server-dev:cdk-vscroll-data-source": "npm run server-dev -- --env.component cdk-virtual-scroll-data-source",
diff --git a/src/dev-app/system-config.ts b/src/dev-app/system-config.ts
index eeb5fbbcc..cb9429636 100644
--- a/src/dev-app/system-config.ts
+++ b/src/dev-app/system-config.ts
@@ -61,6 +61,7 @@ System.config({
'@ptsecurity/mosaic': 'dist/packages/mosaic/index.js',
'@ptsecurity/mosaic/button': 'dist/packages/mosaic/button/index.js',
+ '@ptsecurity/mosaic/button-toggle': 'dist/packages/mosaic/button-toggle/index.js',
'@ptsecurity/mosaic/core': 'dist/packages/mosaic/core/index.js',
'@ptsecurity/mosaic/card': 'dist/packages/mosaic/card/index.js',
'@ptsecurity/mosaic/datepicker': 'dist/packages/mosaic/datepicker/index.js',
diff --git a/src/lib-dev/all/module.ts b/src/lib-dev/all/module.ts
index 9a43a49c9..92cb2c90a 100644
--- a/src/lib-dev/all/module.ts
+++ b/src/lib-dev/all/module.ts
@@ -8,6 +8,7 @@ import { BrowserModule } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { CdkTreeModule, FlatTreeControl, NestedTreeControl } from '@ptsecurity/cdk/tree';
import { McButtonModule } from '@ptsecurity/mosaic/button';
+import { McButtonToggleModule } from '@ptsecurity/mosaic/button-toggle';
import { McCardModule } from '@ptsecurity/mosaic/card';
import { McCheckboxModule } from '@ptsecurity/mosaic/checkbox';
@@ -54,6 +55,8 @@ export class DemoComponent {
disabled: boolean = false;
labelPosition = 'after';
+ buttonToggleModelResult: string;
+
typesOfShoes = ['Boots', 'Clogs', 'Loafers', 'Moccasins', 'Sneakers'];
folders = [
@@ -194,6 +197,7 @@ export class DemoComponent {
ReactiveFormsModule,
McIconModule,
McButtonModule,
+ McButtonToggleModule,
McLinkModule,
McCardModule,
McCheckboxModule,
diff --git a/src/lib-dev/all/template.html b/src/lib-dev/all/template.html
index 66727d643..a8f3b1a73 100644
--- a/src/lib-dev/all/template.html
+++ b/src/lib-dev/all/template.html
@@ -101,6 +101,133 @@
Button
+
+
Button-Toggle
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+ default 4
+
+
+
+
+
+ default 6
+
+
+
+
+
+
Selected value: {{group1.value}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Selected value: {{disabledGroup.value}}
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default
+
+
+
+
+
+
Selected value: {{multipleGroup.value}}
+
+
+
+
+
+
+
+
+ default
+
+
Selected value: {{standAloneToggle.checked}}
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Selected value: {{group3.value}}
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Model result: {{buttonToggleModelResult}}
+
+
Card
diff --git a/src/lib-dev/button-toggle/module.ts b/src/lib-dev/button-toggle/module.ts
new file mode 100644
index 000000000..ae70747e9
--- /dev/null
+++ b/src/lib-dev/button-toggle/module.ts
@@ -0,0 +1,44 @@
+import { Component, NgModule, ViewEncapsulation } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { BrowserModule } from '@angular/platform-browser';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+import { McIconModule } from '@ptsecurity/mosaic/icon';
+
+import { McButtonModule } from '../../lib/button';
+import { McButtonToggleModule } from '../../lib/button-toggle';
+
+
+@Component({
+ selector: 'app',
+ template: require('./template.html'),
+ styleUrls: ['./styles.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class ButtonToggleDemoComponent {
+ modelResult: any;
+ disabled: boolean;
+}
+
+
+@NgModule({
+ declarations: [
+ ButtonToggleDemoComponent
+ ],
+ imports: [
+ BrowserModule,
+ McButtonModule,
+ McButtonToggleModule,
+ McIconModule,
+ FormsModule
+ ],
+ bootstrap: [
+ ButtonToggleDemoComponent
+ ]
+})
+export class ButtonToggleDemoModule {}
+
+platformBrowserDynamic()
+ .bootstrapModule(ButtonToggleDemoModule)
+ // tslint:disable-next-line:no-console
+ .catch((error) => console.error(error));
+
diff --git a/src/lib-dev/button-toggle/styles.scss b/src/lib-dev/button-toggle/styles.scss
new file mode 100644
index 000000000..885247b56
--- /dev/null
+++ b/src/lib-dev/button-toggle/styles.scss
@@ -0,0 +1,4 @@
+@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons';
+
+// @import '../../lib/core/theming/prebuilt/default-theme';
+@import '../../lib/core/theming/prebuilt/dark-theme';
diff --git a/src/lib-dev/button-toggle/template.html b/src/lib-dev/button-toggle/template.html
new file mode 100644
index 000000000..3ed69694e
--- /dev/null
+++ b/src/lib-dev/button-toggle/template.html
@@ -0,0 +1,124 @@
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+ default 4
+
+
+
+
+
+ default 6
+
+
+
+
+
+
Selected value: {{group1.value}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Selected value: {{disabledGroup.value}}
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Selected value: {{multipleGroup.value}}
+
+
+
+
+
+
+
+
+ default
+
+
Selected value: {{standAloneToggle.checked}}
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Selected value: {{group3.value}}
+
+
+
+
+
+
+
+ default 1
+
+
+ default 2
+
+
+ default 3
+
+
+
+
+
+
Model result: {{modelResult}}
+
diff --git a/src/lib/button-toggle/README.md b/src/lib/button-toggle/README.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/lib/button-toggle/_button-toggle-theme.scss b/src/lib/button-toggle/_button-toggle-theme.scss
new file mode 100644
index 000000000..5a70aaa08
--- /dev/null
+++ b/src/lib/button-toggle/_button-toggle-theme.scss
@@ -0,0 +1,48 @@
+@import '../core/theming/palette';
+@import '../core/theming/theming';
+@import '../core/styles/typography/typography-utils';
+@import '../button/button-theme';
+
+@mixin mc-button-toggle-theme($theme) {
+ $primary: map-get($theme, primary);
+ $second: map-get($theme, second);
+ $foreground: map-get($theme, foreground);
+ $background: map-get($theme, background);
+ $divider-color: mc-color($foreground, divider);
+
+ .mc-button-toggle-standalone {
+ box-shadow: none;
+ }
+
+ .mc-button-toggle-vertical {
+ .mc-button-toggle + .mc-button-toggle {
+ border-left: none;
+ border-right: none;
+ }
+ }
+
+ .mc-button-toggle[disabled] {
+ outline: 0;
+ }
+
+ .mc-button-toggle-checked:not([disabled]) {
+ .mc-button,
+ .mc-icon-button {
+ background: darken(map-get($background, button-bg), 5);
+ }
+
+ &:not(.cdk-keyboard-focused) {
+ .mc-button,
+ .mc-icon-button {
+ border-color: darken(mc-color($background, button-border), 5);
+ box-shadow: map-get($background, in-shadow);
+ }
+ }
+ }
+}
+
+@mixin mc-button-toggle-typography($config) {
+ .mc-button-toggle {
+ font-family: mc-font-family($config);
+ }
+}
diff --git a/src/lib/button-toggle/button-toggle.component.spec.ts b/src/lib/button-toggle/button-toggle.component.spec.ts
new file mode 100644
index 000000000..3e5eb7b75
--- /dev/null
+++ b/src/lib/button-toggle/button-toggle.component.spec.ts
@@ -0,0 +1,781 @@
+import { Component, DebugElement, QueryList, ViewChild, ViewChildren } from '@angular/core';
+import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
+import { FormControl, FormsModule, NgModel, ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { McButtonModule } from '@ptsecurity/mosaic/button';
+
+import { McButtonToggle, McButtonToggleChange, McButtonToggleGroup, McButtonToggleModule } from './index';
+
+
+describe('McButtonToggle with forms', () => {
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [McButtonModule, McButtonToggleModule, FormsModule, ReactiveFormsModule],
+ declarations: [
+ ButtonToggleGroupWithNgModel,
+ ButtonToggleGroupWithFormControl
+ ]
+ });
+
+ TestBed.compileComponents();
+ }));
+
+ describe('using FormControl', () => {
+ let fixture: ComponentFixture
;
+ let groupDebugElement: DebugElement;
+ let groupInstance: McButtonToggleGroup;
+ let testComponent: ButtonToggleGroupWithFormControl;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(ButtonToggleGroupWithFormControl);
+ fixture.detectChanges();
+
+ testComponent = fixture.debugElement.componentInstance;
+
+ groupDebugElement = fixture.debugElement.query(By.directive(McButtonToggleGroup));
+ groupInstance = groupDebugElement.injector.get(McButtonToggleGroup);
+ }));
+
+ it('should toggle the disabled state', () => {
+ testComponent.control.disable();
+
+ expect(groupInstance.disabled).toBe(true);
+
+ testComponent.control.enable();
+
+ expect(groupInstance.disabled).toBe(false);
+ });
+
+ it('should set the value', () => {
+ testComponent.control.setValue('green');
+
+ expect(groupInstance.value).toBe('green');
+
+ testComponent.control.setValue('red');
+
+ expect(groupInstance.value).toBe('red');
+ });
+
+ it('should register the on change callback', () => {
+ const spy = jasmine.createSpy('onChange callback');
+
+ testComponent.control.registerOnChange(spy);
+ testComponent.control.setValue('blue');
+
+ expect(spy).toHaveBeenCalled();
+ });
+ });
+
+ describe('button toggle group with ngModel and change event', () => {
+ let fixture: ComponentFixture;
+ let groupDebugElement: DebugElement;
+ let buttonToggleDebugElements: DebugElement[];
+ let groupInstance: McButtonToggleGroup;
+ let buttonToggleInstances: McButtonToggle[];
+ let testComponent: ButtonToggleGroupWithNgModel;
+ let groupNgModel: NgModel;
+ let innerButtons: HTMLElement[];
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(ButtonToggleGroupWithNgModel);
+ fixture.detectChanges();
+ testComponent = fixture.debugElement.componentInstance;
+
+ groupDebugElement = fixture.debugElement.query(By.directive(McButtonToggleGroup));
+ groupInstance = groupDebugElement.injector.get(McButtonToggleGroup);
+ groupNgModel = groupDebugElement.injector.get(NgModel);
+
+ buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(McButtonToggle));
+ buttonToggleInstances = buttonToggleDebugElements.map((debugEl) => debugEl.componentInstance);
+ innerButtons = buttonToggleDebugElements.map(
+ (debugEl) => debugEl.query(By.css('button')).nativeElement);
+
+ fixture.detectChanges();
+ }));
+
+ it('should update the model before firing change event', fakeAsync(() => {
+ expect(testComponent.modelValue).toBeUndefined();
+ expect(testComponent.lastEvent).toBeUndefined();
+
+ innerButtons[0].click();
+ fixture.detectChanges();
+
+ tick();
+ expect(testComponent.modelValue).toBe('red');
+ expect(testComponent.lastEvent.value).toBe('red');
+ }));
+
+ it('should check the corresponding button toggle on a group value change', () => {
+ expect(groupInstance.value).toBeFalsy();
+
+ for (const buttonToggle of buttonToggleInstances) {
+ expect(buttonToggle.checked).toBeFalsy();
+ }
+
+ groupInstance.value = 'red';
+
+ for (const buttonToggle of buttonToggleInstances) {
+ expect(buttonToggle.checked).toBe(groupInstance.value === buttonToggle.value);
+ }
+
+ const selected = groupInstance.selected as McButtonToggle;
+
+ expect(selected.value).toBe(groupInstance.value);
+ });
+
+ it('should have the correct FormControl state initially and after interaction',
+ fakeAsync(() => {
+ expect(groupNgModel.valid).toBe(true);
+ expect(groupNgModel.pristine).toBe(true);
+ expect(groupNgModel.touched).toBe(false);
+
+ buttonToggleInstances[1].checked = true;
+ fixture.detectChanges();
+ tick();
+
+ expect(groupNgModel.valid).toBe(true);
+ expect(groupNgModel.pristine).toBe(true);
+ expect(groupNgModel.touched).toBe(false);
+
+ // tslint:disable-next-line:no-magic-numbers
+ innerButtons[2].click();
+ fixture.detectChanges();
+ tick();
+
+ expect(groupNgModel.valid).toBe(true);
+ expect(groupNgModel.pristine).toBe(false);
+ expect(groupNgModel.touched).toBe(true);
+ }));
+
+ it('should update the ngModel value when selecting a button toggle', fakeAsync(() => {
+ innerButtons[1].click();
+ fixture.detectChanges();
+
+ tick();
+
+ expect(testComponent.modelValue).toBe('green');
+ }));
+ });
+});
+
+describe('McButtonToggle without forms', () => {
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [McButtonModule, McButtonToggleModule],
+ declarations: [
+ ButtonTogglesInsideButtonToggleGroup,
+ ButtonTogglesInsideButtonToggleGroupMultiple,
+ FalsyButtonTogglesInsideButtonToggleGroupMultiple,
+ ButtonToggleGroupWithInitialValue,
+ StandaloneButtonToggle,
+ RepeatedButtonTogglesWithPreselectedValue
+ ]
+ });
+
+ TestBed.compileComponents();
+ }));
+
+ describe('inside of an exclusive selection group', () => {
+
+ let fixture: ComponentFixture;
+ let groupDebugElement: DebugElement;
+ let groupNativeElement: HTMLElement;
+ let buttonToggleDebugElements: DebugElement[];
+ let buttonToggleNativeElements: HTMLElement[];
+ let buttonToggleLabelElements: HTMLLabelElement[];
+ let groupInstance: McButtonToggleGroup;
+ let buttonToggleInstances: McButtonToggle[];
+ let testComponent: ButtonTogglesInsideButtonToggleGroup;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ButtonTogglesInsideButtonToggleGroup);
+ fixture.detectChanges();
+
+ testComponent = fixture.debugElement.componentInstance;
+
+ groupDebugElement = fixture.debugElement.query(By.directive(McButtonToggleGroup));
+ groupNativeElement = groupDebugElement.nativeElement;
+ groupInstance = groupDebugElement.injector.get(McButtonToggleGroup);
+
+ buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(McButtonToggle));
+
+ buttonToggleNativeElements = buttonToggleDebugElements
+ .map((debugEl) => debugEl.nativeElement);
+
+ buttonToggleLabelElements = fixture.debugElement.queryAll(By.css('button'))
+ .map((debugEl) => debugEl.nativeElement);
+
+ buttonToggleInstances = buttonToggleDebugElements.map((debugEl) => debugEl.componentInstance);
+ });
+
+ it('should disable click interactions when the group is disabled', () => {
+ testComponent.isGroupDisabled = true;
+ fixture.detectChanges();
+
+ buttonToggleNativeElements[0].click();
+
+ expect(buttonToggleInstances[0].checked).toBe(false);
+ testComponent.isGroupDisabled = false;
+
+ fixture.detectChanges();
+
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ });
+
+ it('should disable the underlying button when the group is disabled', () => {
+ const buttons = buttonToggleNativeElements.map((toggle) => toggle.querySelector('button')!);
+
+ expect(buttons.every((input) => input.disabled)).toBe(false);
+
+ testComponent.isGroupDisabled = true;
+ fixture.detectChanges();
+
+ expect(buttons.every((input) => input.disabled)).toBe(true);
+ });
+
+ it('should update the group value when one of the toggles changes', () => {
+ expect(groupInstance.value).toBeFalsy();
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test1');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[0]);
+ });
+
+ it('should propagate the value change back up via a two-way binding', () => {
+ expect(groupInstance.value).toBeFalsy();
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test1');
+ expect(testComponent.groupValue).toBe('test1');
+ });
+
+ it('should update the group and toggles when one of the button toggles is clicked', () => {
+ expect(groupInstance.value).toBeFalsy();
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test1');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[0]);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ expect(buttonToggleInstances[1].checked).toBe(false);
+
+ buttonToggleLabelElements[1].click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test2');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[1]);
+ expect(buttonToggleInstances[0].checked).toBe(false);
+ expect(buttonToggleInstances[1].checked).toBe(true);
+ });
+
+ it('should check a button toggle upon interaction with underlying native radio button', () => {
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ expect(groupInstance.value);
+ });
+
+ it('should change the vertical state', () => {
+ expect(groupNativeElement.classList).not.toContain('mc-button-toggle-vertical');
+
+ groupInstance.vertical = true;
+ fixture.detectChanges();
+
+ expect(groupNativeElement.classList).toContain('mc-button-toggle-vertical');
+ });
+
+ it('should emit a change event from button toggles', fakeAsync(() => {
+ expect(buttonToggleInstances[0].checked).toBe(false);
+
+ const changeSpy = jasmine.createSpy('button-toggle change listener');
+ buttonToggleInstances[0].change.subscribe(changeSpy);
+
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+ tick();
+ expect(changeSpy).toHaveBeenCalledTimes(1);
+
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+ tick();
+
+ // Always emit change event when button toggle is clicked
+ // tslint:disable-next-line:no-magic-numbers
+ expect(changeSpy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should emit a change event from the button toggle group', fakeAsync(() => {
+ expect(groupInstance.value).toBeFalsy();
+
+ const changeSpy = jasmine.createSpy('button-toggle-group change listener');
+ groupInstance.change.subscribe(changeSpy);
+
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+ tick();
+ expect(changeSpy).toHaveBeenCalled();
+
+ buttonToggleLabelElements[1].click();
+ fixture.detectChanges();
+ tick();
+ // tslint:disable-next-line:no-magic-numbers
+ expect(changeSpy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should update the group and button toggles when updating the group value', () => {
+ expect(groupInstance.value).toBeFalsy();
+
+ testComponent.groupValue = 'test1';
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test1');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[0]);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ expect(buttonToggleInstances[1].checked).toBe(false);
+
+ testComponent.groupValue = 'test2';
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test2');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[1]);
+ expect(buttonToggleInstances[0].checked).toBe(false);
+ expect(buttonToggleInstances[1].checked).toBe(true);
+ });
+
+ it('should deselect all of the checkboxes when the group value is cleared', () => {
+ buttonToggleInstances[0].checked = true;
+
+ expect(groupInstance.value).toBeTruthy();
+
+ groupInstance.value = null;
+
+ expect(buttonToggleInstances.every((toggle) => !toggle.checked)).toBe(true);
+ });
+
+ it('should update the model if a selected toggle is removed', fakeAsync(() => {
+ expect(groupInstance.value).toBeFalsy();
+ buttonToggleLabelElements[0].click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toBe('test1');
+ expect(groupInstance.selected).toBe(buttonToggleInstances[0]);
+
+ testComponent.renderFirstToggle = false;
+ fixture.detectChanges();
+ tick();
+
+ expect(groupInstance.value).toBeFalsy();
+ expect(groupInstance.selected).toBeFalsy();
+ }));
+
+ });
+
+ describe('with initial value and change event', () => {
+
+ it('should not fire an initial change event', () => {
+ const fixture = TestBed.createComponent(ButtonToggleGroupWithInitialValue);
+ const testComponent = fixture.debugElement.componentInstance;
+ const groupDebugElement = fixture.debugElement.query(By.directive(McButtonToggleGroup));
+ const groupInstance: McButtonToggleGroup = groupDebugElement.injector
+ .get(McButtonToggleGroup);
+
+ fixture.detectChanges();
+
+ // Note that we cast to a boolean, because the event has some circular references
+ // which will crash the runner when Jasmine attempts to stringify them.
+ expect(!!testComponent.lastEvent).toBe(false);
+ expect(groupInstance.value).toBe('red');
+
+ groupInstance.value = 'green';
+ fixture.detectChanges();
+
+ expect(!!testComponent.lastEvent).toBe(false);
+ expect(groupInstance.value).toBe('green');
+ });
+
+ });
+
+ describe('inside of a multiple selection group', () => {
+ let fixture: ComponentFixture;
+ let groupDebugElement: DebugElement;
+ let groupNativeElement: HTMLElement;
+ let buttonToggleDebugElements: DebugElement[];
+ let buttonToggleNativeElements: HTMLElement[];
+ let buttonToggleButtonElements: HTMLLabelElement[];
+ let groupInstance: McButtonToggleGroup;
+ let buttonToggleInstances: McButtonToggle[];
+ let testComponent: ButtonTogglesInsideButtonToggleGroupMultiple;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(ButtonTogglesInsideButtonToggleGroupMultiple);
+ fixture.detectChanges();
+
+ testComponent = fixture.debugElement.componentInstance;
+
+ groupDebugElement = fixture.debugElement.query(By.directive(McButtonToggleGroup));
+ groupNativeElement = groupDebugElement.nativeElement;
+ groupInstance = groupDebugElement.injector.get(McButtonToggleGroup);
+
+ buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(McButtonToggle));
+ buttonToggleNativeElements = buttonToggleDebugElements
+ .map((debugEl) => debugEl.nativeElement);
+ buttonToggleButtonElements = fixture.debugElement.queryAll(By.css('button'))
+ .map((debugEl) => debugEl.nativeElement);
+ buttonToggleInstances = buttonToggleDebugElements.map((debugEl) => debugEl.componentInstance);
+ }));
+
+ it('should disable click interactions when the group is disabled', () => {
+ testComponent.isGroupDisabled = true;
+ fixture.detectChanges();
+
+ buttonToggleNativeElements[0].click();
+ expect(buttonToggleInstances[0].checked).toBe(false);
+ });
+
+ it('should check a button toggle when clicked', () => {
+ expect(buttonToggleInstances.every((buttonToggle) => !buttonToggle.checked)).toBe(true);
+
+ const nativeCheckboxLabel = buttonToggleDebugElements[0].query(By.css('button')).nativeElement;
+
+ nativeCheckboxLabel.click();
+
+ expect(groupInstance.value).toEqual(['eggs']);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ });
+
+ it('should allow for multiple toggles to be selected', () => {
+ buttonToggleInstances[0].checked = true;
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toEqual(['eggs']);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+
+ buttonToggleInstances[1].checked = true;
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toEqual(['eggs', 'flour']);
+ expect(buttonToggleInstances[1].checked).toBe(true);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ });
+
+ it('should check a button toggle upon interaction with underlying native checkbox', () => {
+ const nativeCheckboxButton = buttonToggleDebugElements[0].query(By.css('button')).nativeElement;
+
+ nativeCheckboxButton.click();
+ fixture.detectChanges();
+
+ expect(groupInstance.value).toEqual(['eggs']);
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ });
+
+ it('should change the vertical state', () => {
+ expect(groupNativeElement.classList).not.toContain('mc-button-toggle-vertical');
+
+ groupInstance.vertical = true;
+ fixture.detectChanges();
+
+ expect(groupNativeElement.classList).toContain('mc-button-toggle-vertical');
+ });
+
+ it('should deselect a button toggle when selected twice', fakeAsync(() => {
+ buttonToggleButtonElements[0].click();
+ fixture.detectChanges();
+ tick();
+
+ expect(buttonToggleInstances[0].checked).toBe(true);
+ expect(groupInstance.value).toEqual(['eggs']);
+
+ buttonToggleButtonElements[0].click();
+ fixture.detectChanges();
+ tick();
+
+ expect(groupInstance.value).toEqual([]);
+ expect(buttonToggleInstances[0].checked).toBe(false);
+ }));
+
+ it('should emit a change event for state changes', fakeAsync(() => {
+ expect(buttonToggleInstances[0].checked).toBe(false);
+
+ const changeSpy = jasmine.createSpy('button-toggle change listener');
+ buttonToggleInstances[0].change.subscribe(changeSpy);
+
+ buttonToggleButtonElements[0].click();
+ fixture.detectChanges();
+ tick();
+ expect(changeSpy).toHaveBeenCalled();
+ expect(groupInstance.value).toEqual(['eggs']);
+
+ buttonToggleButtonElements[0].click();
+ fixture.detectChanges();
+ tick();
+ expect(groupInstance.value).toEqual([]);
+
+ // The default browser behavior is to emit an event, when the value was set
+ // to false. That's because the current input type is set to `checkbox` when
+ // using the multiple mode.
+ // tslint:disable-next-line:no-magic-numbers
+ expect(changeSpy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should throw when attempting to assign a non-array value', () => {
+ expect(() => {
+ groupInstance.value = 'not-an-array';
+ }).toThrowError(/Value must be an array/);
+ });
+ });
+
+ describe('as standalone', () => {
+ let fixture: ComponentFixture;
+ let buttonToggleDebugElement: DebugElement;
+ let buttonToggleButtonElement: HTMLLabelElement;
+ let buttonToggleInstance: McButtonToggle;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(StandaloneButtonToggle);
+ fixture.detectChanges();
+
+ buttonToggleDebugElement = fixture.debugElement.query(By.directive(McButtonToggle));
+ buttonToggleButtonElement = fixture.debugElement.query(By.css('button')).nativeElement;
+
+ buttonToggleInstance = buttonToggleDebugElement.componentInstance;
+ }));
+
+ it('should toggle when clicked', fakeAsync(() => {
+ buttonToggleButtonElement.click();
+ fixture.detectChanges();
+ flush();
+
+ expect(buttonToggleInstance.checked).toBe(true);
+
+ buttonToggleButtonElement.click();
+ fixture.detectChanges();
+ flush();
+
+ expect(buttonToggleInstance.checked).toBe(false);
+ }));
+
+ it('should emit a change event for state changes', fakeAsync(() => {
+
+ expect(buttonToggleInstance.checked).toBe(false);
+
+ const changeSpy = jasmine.createSpy('button-toggle change listener');
+ buttonToggleInstance.change.subscribe(changeSpy);
+
+ buttonToggleButtonElement.click();
+ fixture.detectChanges();
+ tick();
+ expect(changeSpy).toHaveBeenCalled();
+
+ buttonToggleButtonElement.click();
+ fixture.detectChanges();
+ tick();
+
+ // The default browser behavior is to emit an event, when the value was set
+ // to false. That's because the current input type is set to `checkbox`.
+ // tslint:disable-next-line:no-magic-numbers
+ expect(changeSpy).toHaveBeenCalledTimes(2);
+ }));
+ });
+
+ it('should not throw on init when toggles are repeated and there is an initial value', () => {
+ const fixture = TestBed.createComponent(RepeatedButtonTogglesWithPreselectedValue);
+
+ expect(() => fixture.detectChanges()).not.toThrow();
+ expect(fixture.componentInstance.toggleGroup.value).toBe('Two');
+ expect(fixture.componentInstance.toggles.toArray()[1].checked).toBe(true);
+ });
+
+ it('should maintain the selected state when the value and toggles are swapped out at ' +
+ 'the same time', () => {
+ const fixture = TestBed.createComponent(RepeatedButtonTogglesWithPreselectedValue);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.toggleGroup.value).toBe('Two');
+ expect(fixture.componentInstance.toggles.toArray()[1].checked).toBe(true);
+
+ fixture.componentInstance.possibleValues = ['Five', 'Six', 'Seven'];
+ fixture.componentInstance.value = 'Seven';
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.toggleGroup.value).toBe('Seven');
+ // tslint:disable-next-line:no-magic-numbers
+ expect(fixture.componentInstance.toggles.toArray()[2].checked).toBe(true);
+ });
+
+ it('should select falsy button toggle value in multiple selection', () => {
+ const fixture = TestBed.createComponent(FalsyButtonTogglesInsideButtonToggleGroupMultiple);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.toggles.toArray()[0].checked).toBe(true);
+ expect(fixture.componentInstance.toggles.toArray()[1].checked).toBe(false);
+ // tslint:disable-next-line:no-magic-numbers
+ expect(fixture.componentInstance.toggles.toArray()[2].checked).toBe(false);
+
+ fixture.componentInstance.value = [0, false];
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.toggles.toArray()[0].checked).toBe(true);
+ expect(fixture.componentInstance.toggles.toArray()[1].checked).toBe(false);
+ // tslint:disable-next-line:no-magic-numbers
+ expect(fixture.componentInstance.toggles.toArray()[2].checked).toBe(true);
+ });
+});
+
+@Component({
+ template: `
+
+
+ Test1
+
+
+ Test2
+
+
+ Test3
+
+
+ `
+})
+class ButtonTogglesInsideButtonToggleGroup {
+ isGroupDisabled: boolean = false;
+ isVertical: boolean = false;
+ groupValue: string;
+ renderFirstToggle = true;
+}
+
+@Component({
+ template: `
+
+
+ {{option.label}}
+
+
+ `
+})
+class ButtonToggleGroupWithNgModel {
+ groupName = 'group-name';
+ modelValue: string;
+ options = [
+ {label: 'Red', value: 'red'},
+ {label: 'Green', value: 'green'},
+ {label: 'Blue', value: 'blue'}
+ ];
+ lastEvent: McButtonToggleChange;
+}
+
+@Component({
+ template: `
+
+
+ Eggs
+
+
+ Flour
+
+
+ Sugar
+
+
+ `
+})
+class ButtonTogglesInsideButtonToggleGroupMultiple {
+ isGroupDisabled: boolean = false;
+ isVertical: boolean = false;
+}
+
+@Component({
+ template: `
+
+
+ Eggs
+
+
+ Flour
+
+
+ Sugar
+
+ Sugar
+
+ `
+})
+class FalsyButtonTogglesInsideButtonToggleGroupMultiple {
+ value: ('' | number | null | undefined | boolean)[] = [0];
+ @ViewChildren(McButtonToggle) toggles: QueryList;
+}
+
+@Component({
+ template: `
+
+ Yes
+
+ `
+})
+class StandaloneButtonToggle {
+}
+
+@Component({
+ template: `
+
+
+ Value Red
+
+
+ Value Green
+
+
+ `
+})
+class ButtonToggleGroupWithInitialValue {
+ lastEvent: McButtonToggleChange;
+}
+
+@Component({
+ template: `
+
+
+ Value Red
+
+
+ Value Green
+
+
+ Value Blue
+
+
+ `
+})
+class ButtonToggleGroupWithFormControl {
+ control = new FormControl();
+}
+
+@Component({
+ template: `
+
+
+ {{toggle}}
+
+
+ `
+})
+class RepeatedButtonTogglesWithPreselectedValue {
+ @ViewChild(McButtonToggleGroup) toggleGroup: McButtonToggleGroup;
+ @ViewChildren(McButtonToggle) toggles: QueryList;
+
+ possibleValues = ['One', 'Two', 'Three'];
+ value = 'Two';
+}
+
diff --git a/src/lib/button-toggle/button-toggle.component.ts b/src/lib/button-toggle/button-toggle.component.ts
new file mode 100644
index 000000000..6aef5470e
--- /dev/null
+++ b/src/lib/button-toggle/button-toggle.component.ts
@@ -0,0 +1,430 @@
+import {
+ AfterContentInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ContentChildren,
+ Directive,
+ ElementRef,
+ EventEmitter,
+ forwardRef,
+ Input,
+ OnDestroy,
+ OnInit,
+ Optional,
+ Output,
+ QueryList,
+ ViewEncapsulation,
+ ViewChild
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { FocusMonitor } from '@ptsecurity/cdk/a11y';
+import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion';
+import { SelectionModel } from '@ptsecurity/cdk/collections';
+import { McButton } from '@ptsecurity/mosaic/button';
+
+
+/** Acceptable types for a button toggle. */
+export type ToggleType = 'checkbox' | 'radio';
+
+/**
+ * Provider Expression that allows mc-button-toggle-group to register as a ControlValueAccessor.
+ * This allows it to support [(ngModel)].
+ * @docs-private
+ */
+export const MC_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR: any = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => McButtonToggleGroup),
+ multi: true
+};
+
+/** Change event object emitted by MсButtonToggle. */
+export class McButtonToggleChange {
+ constructor(
+ /** The MсButtonToggle that emits the event. */
+ public source: McButtonToggle,
+ /** The value assigned to the MсButtonToggle. */
+ public value: any) {
+ }
+}
+
+/** Exclusive selection button toggle group that behaves like a radio-button group. */
+@Directive({
+ selector: 'mc-button-toggle-group',
+ providers: [MC_BUTTON_TOGGLE_GROUP_VALUE_ACCESSOR],
+ host: {
+ role: 'group',
+ class: 'mc-button-toggle-group',
+ '[class.mc-button-toggle-vertical]': 'vertical'
+ },
+ exportAs: 'mcButtonToggleGroup'
+})
+export class McButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
+
+ /** Whether the toggle group is vertical. */
+ @Input()
+ get vertical(): boolean {
+ return this._vertical;
+ }
+
+ set vertical(value: boolean) {
+ this._vertical = coerceBooleanProperty(value);
+ }
+
+ /** Value of the toggle group. */
+ @Input()
+ get value(): any {
+ const selected = this.selectionModel ? this.selectionModel.selected : [];
+
+ if (this.multiple) {
+ return selected.map((toggle) => toggle.value);
+ }
+
+ return selected[0] ? selected[0].value : undefined;
+ }
+
+ set value(newValue: any) {
+ this.setSelectionByValue(newValue);
+ this.valueChange.emit(this.value);
+ }
+
+ /** Selected button toggles in the group. */
+ get selected(): any {
+ const selected = this.selectionModel.selected;
+
+ return this.multiple ? selected : (selected[0] || null);
+ }
+
+ /** Whether multiple button toggles can be selected. */
+ @Input()
+ get multiple(): boolean {
+ return this._multiple;
+ }
+
+ set multiple(value: boolean) {
+ this._multiple = coerceBooleanProperty(value);
+ }
+
+ /** Child button toggle buttons. */
+ @ContentChildren(forwardRef(() => McButtonToggle)) buttonToggles: QueryList;
+
+ /** Whether multiple button toggle group is disabled. */
+ @Input()
+ get disabled(): boolean {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = coerceBooleanProperty(value);
+
+ if (!this.buttonToggles) {
+ return;
+ }
+
+ this.buttonToggles.forEach((toggle) => toggle.markForCheck());
+ }
+
+ /**
+ * Event that emits whenever the value of the group changes.
+ * Used to facilitate two-way data binding.
+ * @docs-private
+ */
+ @Output() readonly valueChange = new EventEmitter();
+
+ /** Event emitted when the group's value changes. */
+ @Output() readonly change: EventEmitter =
+ new EventEmitter();
+ private _vertical = false;
+ private _multiple = false;
+ private _disabled = false;
+ private selectionModel: SelectionModel;
+
+ /**
+ * Reference to the raw value that the consumer tried to assign. The real
+ * value will exclude any values from this one that don't correspond to a
+ * toggle. Useful for the cases where the value is assigned before the toggles
+ * have been initialized or at the same that they're being swapped out.
+ */
+ private rawValue: any;
+
+ constructor(private _changeDetector: ChangeDetectorRef) {}
+
+ /**
+ * The method to be called in order to update ngModel.
+ * Now `ngModel` binding is not supported in multiple selection mode.
+ */
+ // tslint:disable-next-line:no-empty
+ controlValueAccessorChangeFn: (value: any) => void = () => {};
+
+ /** onTouch function registered via registerOnTouch (ControlValueAccessor). */
+ // tslint:disable-next-line:no-empty
+ onTouched: () => any = () => {};
+
+ ngOnInit() {
+ this.selectionModel = new SelectionModel(this.multiple, undefined, false);
+ }
+
+ ngAfterContentInit() {
+ this.selectionModel.select(...this.buttonToggles.filter((toggle) => toggle.checked));
+ this.disabled = this._disabled;
+ }
+
+ /**
+ * Sets the model value. Implemented as part of ControlValueAccessor.
+ * @param value Value to be set to the model.
+ */
+ writeValue(value: any) {
+ this.value = value;
+ this._changeDetector.markForCheck();
+ }
+
+ // Implemented as part of ControlValueAccessor.
+ registerOnChange(fn: (value: any) => void) {
+ this.controlValueAccessorChangeFn = fn;
+ }
+
+ // Implemented as part of ControlValueAccessor.
+ registerOnTouched(fn: any) {
+ this.onTouched = fn;
+ }
+
+ // Implemented as part of ControlValueAccessor.
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+
+ /** Dispatch change event with current selection and group value. */
+ emitChangeEvent(): void {
+ const selected = this.selected;
+ const source = Array.isArray(selected) ? selected[selected.length - 1] : selected;
+ const event = new McButtonToggleChange(source, this.value);
+ this.controlValueAccessorChangeFn(event.value);
+ this.change.emit(event);
+ }
+
+ /**
+ * Syncs a button toggle's selected state with the model value.
+ * @param toggle Toggle to be synced.
+ * @param select Whether the toggle should be selected.
+ * @param isUserInput Whether the change was a result of a user interaction.
+ */
+ syncButtonToggle(toggle: McButtonToggle, select: boolean, isUserInput = false) {
+ // Deselect the currently-selected toggle, if we're in single-selection
+ // mode and the button being toggled isn't selected at the moment.
+ if (!this.multiple && this.selected && !toggle.checked) {
+ (this.selected as McButtonToggle).checked = false;
+ }
+
+ if (select) {
+ this.selectionModel.select(toggle);
+ } else {
+ this.selectionModel.deselect(toggle);
+ }
+
+ // Only emit the change event for user input.
+ if (isUserInput) {
+ this.emitChangeEvent();
+ }
+
+ // Note: we emit this one no matter whether it was a user interaction, because
+ // it is used by Angular to sync up the two-way data binding.
+ this.valueChange.emit(this.value);
+ }
+
+ /** Checks whether a button toggle is selected. */
+ isSelected(toggle: McButtonToggle) {
+ return this.selectionModel.isSelected(toggle);
+ }
+
+ /** Determines whether a button toggle should be checked on init. */
+ isPrechecked(toggle: McButtonToggle) {
+ if (this.rawValue === undefined) {
+ return false;
+ }
+
+ if (this.multiple && Array.isArray(this.rawValue)) {
+ return this.rawValue.some((value) => toggle.value != null && value === toggle.value);
+ }
+
+ return toggle.value === this.rawValue;
+ }
+
+ /** Updates the selection state of the toggles in the group based on a value. */
+ private setSelectionByValue(value: any | any[]) {
+ this.rawValue = value;
+
+ if (!this.buttonToggles) {
+ return;
+ }
+
+ if (this.multiple && value) {
+ if (!Array.isArray(value)) {
+ throw Error('Value must be an array in multiple-selection mode.');
+ }
+
+ this.clearSelection();
+ value.forEach((currentValue: any) => this.selectValue(currentValue));
+ } else {
+ this.clearSelection();
+ this.selectValue(value);
+ }
+ }
+
+ /** Clears the selected toggles. */
+ private clearSelection() {
+ this.selectionModel.clear();
+ this.buttonToggles.forEach((toggle) => toggle.checked = false);
+ }
+
+ /** Selects a value if there's a toggle that corresponds to it. */
+ private selectValue(value: any) {
+ const correspondingOption = this.buttonToggles.find((toggle) => {
+ return toggle.value != null && toggle.value === value;
+ });
+
+ if (correspondingOption) {
+ correspondingOption.checked = true;
+ this.selectionModel.select(correspondingOption);
+ }
+ }
+}
+
+/** Single button inside of a toggle group. */
+@Component({
+ selector: 'mc-button-toggle',
+ template: `
+
+ `,
+ styleUrls: ['button-toggle.css'],
+ encapsulation: ViewEncapsulation.None,
+ exportAs: 'mcButtonToggle',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ '[class.mc-button-toggle-standalone]': '!buttonToggleGroup',
+ '[class.mc-button-toggle-checked]': 'checked',
+ class: 'mc-button-toggle',
+ // Always reset the tabindex to -1 so it doesn't conflict with the one on the `button`,
+ // but can still receive focus from things like cdkFocusInitial.
+ '[attr.tabindex]': '-1',
+ '[attr.disabled]': 'disabled || null',
+ '(focus)': 'focus()'
+ }
+})
+export class McButtonToggle implements OnInit, OnDestroy {
+
+ /** Whether the button is checked. */
+ @Input()
+ get checked(): boolean {
+ return this.buttonToggleGroup ? this.buttonToggleGroup.isSelected(this) : this._checked;
+ }
+
+ set checked(value: boolean) {
+ const newValue = coerceBooleanProperty(value);
+
+ if (newValue !== this._checked) {
+ this._checked = newValue;
+
+ if (this.buttonToggleGroup) {
+ this.buttonToggleGroup.syncButtonToggle(this, this._checked);
+ }
+
+ this.changeDetectorRef.markForCheck();
+ }
+ }
+
+ // tslint:disable-next-line:no-reserved-keywords
+ type: ToggleType;
+
+ @ViewChild(McButton) mcButton: McButton;
+
+ /** McButtonToggleGroup reads this to assign its own value. */
+ @Input() value: any;
+
+ /** Tabindex for the toggle. */
+ @Input() tabIndex: number | null;
+
+ @Input()
+ get disabled(): boolean {
+ return this._disabled || (this.buttonToggleGroup && this.buttonToggleGroup.disabled);
+ }
+ set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); }
+
+ /** Event emitted when the group value changes. */
+ @Output() readonly change: EventEmitter =
+ new EventEmitter();
+
+ private isSingleSelector = false;
+ private _checked = false;
+ private _disabled: boolean = false;
+
+ constructor(
+ @Optional() public buttonToggleGroup: McButtonToggleGroup,
+ private changeDetectorRef: ChangeDetectorRef,
+ private focusMonitor: FocusMonitor,
+ private element: ElementRef
+ ) {}
+
+ ngOnInit() {
+ this.isSingleSelector = this.buttonToggleGroup && !this.buttonToggleGroup.multiple;
+ this.type = this.isSingleSelector ? 'radio' : 'checkbox';
+
+ if (this.buttonToggleGroup && this.buttonToggleGroup.isPrechecked(this)) {
+ this.checked = true;
+ }
+
+ this.focusMonitor.monitor(this.element.nativeElement, true);
+ }
+
+ ngOnDestroy() {
+ const group = this.buttonToggleGroup;
+
+ this.focusMonitor.stopMonitoring(this.element.nativeElement);
+
+ // Remove the toggle from the selection once it's destroyed. Needs to happen
+ // on the next tick in order to avoid "changed after checked" errors.
+ if (group && group.isSelected(this)) {
+ Promise.resolve().then(() => group.syncButtonToggle(this, false));
+ }
+ }
+
+ /** Focuses the button. */
+ focus(): void {
+ this.element.nativeElement.focus();
+ }
+
+ /** Checks the button toggle due to an interaction with the underlying native button. */
+ onToggleClick() {
+ if (this.disabled) {
+ return;
+ }
+
+ const newChecked = this.isSingleSelector ? true : !this._checked;
+
+ if (newChecked !== this._checked) {
+ this._checked = newChecked;
+ if (this.buttonToggleGroup) {
+ this.buttonToggleGroup.syncButtonToggle(this, this._checked, true);
+ this.buttonToggleGroup.onTouched();
+ }
+ }
+ // Emit a change event when it's the single selector
+ this.change.emit(new McButtonToggleChange(this, this.value));
+ }
+
+ /**
+ * Marks the button toggle as needing checking for change detection.
+ * This method is exposed because the parent button toggle group will directly
+ * update bound properties of the radio button.
+ */
+ markForCheck() {
+ // When the group value changes, the button will not be notified.
+ // Use `markForCheck` to explicit update button toggle's status.
+ this.changeDetectorRef.markForCheck();
+ }
+}
diff --git a/src/lib/button-toggle/button-toggle.module.ts b/src/lib/button-toggle/button-toggle.module.ts
new file mode 100644
index 000000000..e9b29c6f9
--- /dev/null
+++ b/src/lib/button-toggle/button-toggle.module.ts
@@ -0,0 +1,13 @@
+import { NgModule } from '@angular/core';
+import { McButtonModule } from '@ptsecurity/mosaic/button';
+import { McCommonModule } from '@ptsecurity/mosaic/core';
+
+import { McButtonToggle, McButtonToggleGroup } from './button-toggle.component';
+
+
+@NgModule({
+ imports: [McCommonModule, McButtonModule],
+ exports: [McCommonModule, McButtonToggleGroup, McButtonToggle],
+ declarations: [McButtonToggleGroup, McButtonToggle]
+})
+export class McButtonToggleModule {}
diff --git a/src/lib/button-toggle/button-toggle.scss b/src/lib/button-toggle/button-toggle.scss
new file mode 100644
index 000000000..371c3c604
--- /dev/null
+++ b/src/lib/button-toggle/button-toggle.scss
@@ -0,0 +1,93 @@
+@import '../core/styles/common/vendor-prefixes';
+@import '../core/styles/common/layout';
+@import '../../cdk/a11y/a11y';
+@import '../core/styles/common/button';
+
+$mc-button-toggle-standard-padding: 0 12px !default;
+$mc-button-toggle-standard-height: 20px !default;
+$mc-button-toggle-standard-border-radius: 2px !default;
+
+$mc-button-toggle-border-size: 1px;
+$mc-button-toggle-border-radius: 3px;
+
+$mc-button-toggle-padding: 5px 7px;
+
+.mc-button-toggle-group {
+ display: flex;
+ flex-direction: row;
+
+ &:not(.mc-button-toggle-vertical) {
+ .mc-button-toggle:not([disabled]) + .mc-button-toggle:not([disabled]) {
+ margin-left: -$mc-button-toggle-border-size;
+ }
+ }
+
+ .mc-button-toggle {
+
+ > .mc-button {
+ padding: $mc-button-toggle-padding;
+ }
+
+ &:first-of-type:not(:last-of-type) {
+ > .mc-button,
+ > .mc-icon-button {
+ @include border-right-radius(0);
+ }
+ }
+
+ &:last-of-type:not(:first-of-type) {
+ > .mc-button,
+ > .mc-icon-button {
+ @include border-left-radius(0);
+ }
+ }
+
+ &:not(:first-of-type):not(:last-of-type) {
+ > .mc-button,
+ > .mc-icon-button {
+ border-radius: 0;
+ }
+ }
+
+ }
+}
+
+.mc-button-toggle-vertical {
+ flex-direction: column;
+
+ .mc-button-toggle:not([disabled]) + .mc-button-toggle:not([disabled]) {
+ margin-top: -$mc-button-toggle-border-size;
+ }
+
+ .mc-button-toggle {
+ .mc-button,
+ .mc-icon-button {
+ width: 100%;
+ }
+
+ &:first-child:not(:last-child) {
+ > .mc-button,
+ > .mc-icon-button {
+ @include border-bottom-radius(0);
+
+ border-top-right-radius: $mc-button-toggle-border-radius;
+ }
+ }
+
+ &:last-child:not(:first-child) {
+ > .mc-button,
+ > .mc-icon-button {
+ @include border-top-radius(0);
+
+ border-bottom-left-radius: $mc-button-toggle-border-radius;
+ }
+ }
+
+ &:not(:first-child):not(:last-child) {
+ > .mc-button,
+ > .mc-icon-button {
+ border-radius: 0;
+ }
+ }
+ }
+}
diff --git a/src/lib/button-toggle/index.ts b/src/lib/button-toggle/index.ts
new file mode 100644
index 000000000..7e1a213e3
--- /dev/null
+++ b/src/lib/button-toggle/index.ts
@@ -0,0 +1 @@
+export * from './public-api';
diff --git a/src/lib/button-toggle/public-api.ts b/src/lib/button-toggle/public-api.ts
new file mode 100644
index 000000000..bf0faccd5
--- /dev/null
+++ b/src/lib/button-toggle/public-api.ts
@@ -0,0 +1,2 @@
+export * from './button-toggle.module';
+export * from './button-toggle.component';
diff --git a/src/lib/button-toggle/tsconfig.build.json b/src/lib/button-toggle/tsconfig.build.json
new file mode 100644
index 000000000..dad2ec50d
--- /dev/null
+++ b/src/lib/button-toggle/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.build",
+ "files": [
+ "public-api.ts"
+ ],
+ "angularCompilerOptions": {
+ "annotateForClosureCompiler": true,
+ "strictMetadataEmit": true,
+ "flatModuleOutFile": "index.js",
+ "flatModuleId": "@ptsecurity/mosaic/button-toggle",
+ "skipTemplateCodegen": true,
+ "fullTemplateTypeCheck": true
+ }
+}
diff --git a/src/lib/core/styles/typography/_all-typography.scss b/src/lib/core/styles/typography/_all-typography.scss
index 696b95694..637f7d759 100644
--- a/src/lib/core/styles/typography/_all-typography.scss
+++ b/src/lib/core/styles/typography/_all-typography.scss
@@ -2,6 +2,7 @@
@import '../alerts';
@import '../badges';
@import '../../../button/button-theme';
+@import '../../../button-toggle/button-toggle-theme';
@import '../../../checkbox/checkbox-theme';
@import '../../../datepicker/datepicker-theme';
@import '../../../dropdown/dropdown-theme';
@@ -33,6 +34,7 @@
@include mc-alert-typography($config);
@include mc-badge-typography($config);
@include mc-button-typography($config);
+ @include mc-button-toggle-typography($config);
@include mc-checkbox-typography($config);
@include mc-datepicker-typography($config);
@include mc-dropdown-typography($config);
diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss
index b5a46b098..169906846 100644
--- a/src/lib/core/theming/_all-theme.scss
+++ b/src/lib/core/theming/_all-theme.scss
@@ -3,6 +3,7 @@
@import '../styles/badges';
@import '../styles/alerts';
@import '../../button/button-theme';
+@import '../../button-toggle/button-toggle-theme';
@import '../../card/card-theme';
@import '../../checkbox/checkbox-theme';
@import '../../datepicker/datepicker-theme';
@@ -39,6 +40,7 @@
@include mc-alert-theme($theme);
@include mc-badge-theme($theme);
@include mc-button-theme($theme);
+ @include mc-button-toggle-theme($theme);
@include mc-card-theme($theme);
@include mc-datepicker-theme($theme);
@include mc-dropdown-theme($theme);
diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts
index 3769d92c2..508bc10d8 100644
--- a/src/lib/public-api.ts
+++ b/src/lib/public-api.ts
@@ -3,6 +3,7 @@ export * from './version';
export * from '@ptsecurity/mosaic/core';
export * from '@ptsecurity/mosaic/button';
+export * from '@ptsecurity/mosaic/button-toggle';
export * from '@ptsecurity/mosaic/card';
export * from '@ptsecurity/mosaic/checkbox';
export * from '@ptsecurity/mosaic/datepicker';
diff --git a/tests/karma-system-config.js b/tests/karma-system-config.js
index bf05dca7c..0cba8d0c9 100644
--- a/tests/karma-system-config.js
+++ b/tests/karma-system-config.js
@@ -57,6 +57,7 @@ System.config({
'@ptsecurity/mosaic-moment-adapter/adapter': 'dist/packages/mosaic-moment-adapter/adapter/index.js',
'@ptsecurity/mosaic/button': 'dist/packages/mosaic/button/index.js',
+ '@ptsecurity/mosaic/button-toggle': 'dist/packages/mosaic/button-toggle/index.js',
'@ptsecurity/mosaic/core': 'dist/packages/mosaic/core/index.js',
'@ptsecurity/mosaic/divider': 'dist/packages/mosaic/divider/index.js',
'@ptsecurity/mosaic/dropdown': 'dist/packages/mosaic/dropdown/index.js',