diff --git a/projects/angular-ui/src/lib/bao.module.ts b/projects/angular-ui/src/lib/bao.module.ts
index 214953c7..deb8c614 100644
--- a/projects/angular-ui/src/lib/bao.module.ts
+++ b/projects/angular-ui/src/lib/bao.module.ts
@@ -22,6 +22,7 @@ import { BaoModalModule } from './modal/module';
import { BaoHyperlinkModule } from './hyperlink';
import { BaoDropdownMenuModule } from './dropdown-menu';
import { BaoFileModule } from './file/module';
+import { BaoToggleModule } from './toggle';
import { BaoSnackBarModule } from './snack-bar/module';
@NgModule({
@@ -52,6 +53,7 @@ import { BaoSnackBarModule } from './snack-bar/module';
BaoHyperlinkModule,
BaoDropdownMenuModule,
BaoFileModule,
+ BaoToggleModule,
BaoSnackBarModule
// TODO: reactivate once component does not depend on global css BaoBadgeModule,
]
diff --git a/projects/angular-ui/src/lib/toggle/index.ts b/projects/angular-ui/src/lib/toggle/index.ts
new file mode 100644
index 00000000..eb08c0f4
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+export * from './module';
+export * from './toggle.component';
diff --git a/projects/angular-ui/src/lib/toggle/module.ts b/projects/angular-ui/src/lib/toggle/module.ts
new file mode 100644
index 00000000..6478474e
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/module.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+import { ObserversModule } from '@angular/cdk/observers';
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { BaoCommonComponentsModule } from '../common-components/module';
+import { BaoToggleComponent } from './toggle.component';
+
+const TOGGLE_DIRECTIVES = [BaoToggleComponent];
+
+@NgModule({
+ imports: [CommonModule, BaoCommonComponentsModule, ObserversModule],
+ declarations: TOGGLE_DIRECTIVES,
+ exports: TOGGLE_DIRECTIVES
+})
+export class BaoToggleModule {}
diff --git a/projects/angular-ui/src/lib/toggle/tests/toggle.hostcomponent.spec.ts b/projects/angular-ui/src/lib/toggle/tests/toggle.hostcomponent.spec.ts
new file mode 100644
index 00000000..d5a9300a
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/tests/toggle.hostcomponent.spec.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+
+import { Component } from '@angular/core';
+
+@Component({
+ template: `
+
+ Label
+
+ `
+})
+export class TestToggleHostComponent {
+ checked: boolean;
+ disabled: boolean;
+ hiddenLabel: boolean;
+ isFocus: boolean;
+ ariaLabel: string;
+}
diff --git a/projects/angular-ui/src/lib/toggle/toggle.component.html b/projects/angular-ui/src/lib/toggle/toggle.component.html
new file mode 100644
index 00000000..aa2303ac
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/toggle.component.html
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/projects/angular-ui/src/lib/toggle/toggle.component.scss b/projects/angular-ui/src/lib/toggle/toggle.component.scss
new file mode 100644
index 00000000..92315261
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/toggle.component.scss
@@ -0,0 +1,144 @@
+@import "../core/colors";
+
+.bao-toggle {
+ padding-left: 0;
+ display: block;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ min-height: 1.5rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ position: relative;
+ z-index: 1;
+
+ button {
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ width: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ }
+
+ .bao-toggle-switch {
+ padding-right: 3.25rem;
+ color: $ground-reversed;
+ font-weight: 400;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ margin-bottom: 0;
+ position: relative;
+ vertical-align: top;
+ &::before {
+ display: block;
+ position: absolute;
+ box-sizing: border-box;
+ content: "";
+ top: calc(50% - (#{0.75rem}/ 2));
+ right: 0;
+ left: auto;
+ width: 2rem;
+ height: 0.75rem;
+ margin: 0 0.25rem;
+ background-color: $highlight-dark;
+ border: none;
+ border-radius: 0.5rem;
+ }
+
+ &::after {
+ display: block;
+ position: absolute;
+ box-sizing: border-box;
+ content: "";
+ top: calc(50% - 0.6rem);
+ right: 1.25rem;
+ left: auto;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin: 0;
+ background-color: $white;
+ border: $neutral-stroke 1px solid;
+ border-radius: 50%;
+ box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1);
+ transition: transform 0.15s ease-in-out;
+ }
+ }
+
+ .bao-toggle-container {
+ display: flex;
+ flex-wrap: nowrap;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &.bao-toggle-switch-checked {
+ .bao-toggle-switch {
+ &::before {
+ background-color: $highlight-dark;
+ }
+ &::after {
+ background-color: $action;
+ border-color: $action;
+ transition: transform 0.15s ease-in-out;
+ transform: translateX(1.2rem);
+ }
+ }
+ }
+
+ &.bao-toggle-switch-disabled {
+ .bao-toggle-switch {
+ &::before {
+ background-color: $neutral-stroke;
+ border-color: $neutral-stroke;
+ cursor: not-allowed;
+ }
+ &::after {
+ background-color: $underground-2;
+ border-color: $underground-2;
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ &.bao-toggle-switch-hidden-label {
+ .bao-toggle-switch {
+ &::before {
+ top: calc(50% + (#{0.65rem}/ 2));
+ }
+ &::after {
+ top: calc(50% + 0.075rem);
+ }
+ }
+ }
+
+ &.bao-toggle-switch-focus {
+ .bao-toggle-switch {
+ &::after {
+ box-shadow: 0 0 0 2px white, 0 0 0 4px #0079C4;
+ }
+ }
+ }
+
+ &.bao-toggle-label-disabled {
+ .bao-toggle-label {
+ color: $underground-2;
+ cursor: not-allowed;
+ }
+ }
+
+ &.bao-toggle-label-hidden {
+ .bao-toggle-label {
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ width: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ }
+ }
+}
diff --git a/projects/angular-ui/src/lib/toggle/toggle.component.spec.ts b/projects/angular-ui/src/lib/toggle/toggle.component.spec.ts
new file mode 100644
index 00000000..2d9a3859
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/toggle.component.spec.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { BaoToggleComponent } from './index';
+import { TestToggleHostComponent } from './tests/toggle.hostcomponent.spec';
+
+describe('BaoToggleComponent', () => {
+ let testComponent: TestToggleHostComponent;
+ let fixture: ComponentFixture;
+ let toggleDebugElement: DebugElement;
+
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [BaoToggleComponent, TestToggleHostComponent]
+ });
+
+ return TestBed.compileComponents();
+ })
+ );
+ describe('CLASSES', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestToggleHostComponent);
+ testComponent = fixture.componentInstance;
+ fixture.detectChanges();
+ toggleDebugElement = fixture.debugElement.query(By.css('.bao-toggle'));
+ });
+ it('should apply global class', () => {
+ // Default class
+ expect(
+ toggleDebugElement.nativeNode.classList.contains('bao-toggle')
+ ).toBe(true);
+ });
+ it('should apply class related to checked attribute', () => {
+ testComponent.checked = true;
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-switch-checked'
+ )
+ ).toBe(true);
+ });
+ it('should apply classes related to disabled attribute', () => {
+ testComponent.disabled = true;
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-switch-disabled'
+ )
+ )
+ .withContext('disabled switch')
+ .toBe(true);
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-label-disabled'
+ )
+ )
+ .withContext('disabled label')
+ .toBe(true);
+ });
+ it('should apply classes related to hiddenLabel attribute', () => {
+ testComponent.hiddenLabel = true;
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-label-hidden'
+ )
+ )
+ .withContext('hidden label')
+ .toBe(true);
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-switch-hidden-label'
+ )
+ )
+ .withContext('hidden label switch styling')
+ .toBe(true);
+ });
+ it('should apply class related to focus', () => {
+ const focusesElement =
+ fixture.nativeElement.querySelector('#id01-button');
+ focusesElement.dispatchEvent(new Event('focus'));
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.nativeNode.classList.contains(
+ 'bao-toggle-switch-focus'
+ )
+ ).toBe(true);
+ });
+ });
+ describe("ARIA", () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestToggleHostComponent);
+ testComponent = fixture.componentInstance;
+ fixture.detectChanges();
+ toggleDebugElement = fixture.debugElement.query(By.css('.bao-toggle'));
+ });
+ it('should apply aria-label', () => {
+ const ariaLabel = 'test aria-label';
+ testComponent.ariaLabel = ariaLabel;
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.childNodes[0].nativeNode.getAttribute('aria-label')
+ ).toBe(ariaLabel);
+ });
+ it('should apply not apply aria-label', () => {
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.childNodes[0].nativeNode.getAttribute('aria-label')
+ ).toBeNull();
+ });
+ it('should apply aria-labelleby', () => {
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.childNodes[0].nativeNode.getAttribute(
+ 'aria-labelledby'
+ )
+ ).toContain(`id01-arialabelledby`);
+ });
+ it('should apply aria-checked to false', () => {
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.childNodes[0].nativeNode.getAttribute('aria-checked')
+ ).toBeNull();
+ });
+ it('should apply aria-checked to true', () => {
+ testComponent.checked = true;
+ fixture.detectChanges();
+ expect(
+ toggleDebugElement.childNodes[0].nativeNode.getAttribute('aria-checked')
+ ).toBe('true');
+ });
+ });
+});
diff --git a/projects/angular-ui/src/lib/toggle/toggle.component.ts b/projects/angular-ui/src/lib/toggle/toggle.component.ts
new file mode 100644
index 00000000..fca9f832
--- /dev/null
+++ b/projects/angular-ui/src/lib/toggle/toggle.component.ts
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+import { FocusMonitor } from '@angular/cdk/a11y';
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output,
+ ViewChild,
+ ViewEncapsulation,
+} from '@angular/core';
+
+/**
+ * Unique ID for each toggle counter
+ */
+let toggleNextUniqueId = 0;
+
+@Component({
+ selector: 'bao-toggle, [bao-toggle]',
+ templateUrl: 'toggle.component.html',
+ styleUrls: ['./toggle.component.scss'],
+ providers: [],
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: 'bao-toggle',
+ '[class.bao-toggle-label-hidden]': 'hiddenLabel',
+ '[class.bao-toggle-switch-hidden-label]': 'hiddenLabel',
+ '[class.bao-toggle-switch-checked]': 'checked',
+ '[class.bao-toggle-switch-disabled]': 'disabled',
+ '[class.bao-toggle-label-disabled]': 'disabled',
+ '[class.bao-toggle-switch-focus]': 'isFocus',
+ },
+})
+export class BaoToggleComponent implements AfterViewInit, OnInit, OnDestroy {
+ /**
+ * The toggle ID. It is set dynamically with an unique ID by default
+ */
+ @Input()
+ public id!: string;
+
+ /**
+ * The name property of the toggle
+ */
+ @Input() public name?: string;
+
+ /**
+ * The aria-label for web accessibility
+ */
+ @Input('aria-label') public ariaLabel?: string;
+
+ /**
+ * The tooltip to show if disabled
+ */
+ @Input() public toolTip?: string;
+
+ /**
+ * Emitted object on change event
+ */
+ @Output() public readonly change: EventEmitter =
+ new EventEmitter();
+
+ /**
+ * Reference to the button html element
+ */
+ @ViewChild('button', { static: false })
+ private buttonElement!: ElementRef;
+
+ /**
+ * The aria-labeledby id for web accessibilty
+ */
+ public ariaLabelledby?: string;
+
+ /**
+ * The ID of the button html element
+ */
+ public buttonId!: string;
+
+ /**
+ * The focus status of the button html element
+ */
+ public isFocus: boolean = false;
+
+ private _disabled = false;
+ private _checked = false;
+ private _hiddenLabel = false;
+ private _uniqueId = `bao-toggle-${++toggleNextUniqueId}`;
+
+ constructor(
+ private cdr: ChangeDetectorRef,
+ private focusMonitor: FocusMonitor
+ ) {
+ if (!this.id) {
+ this.id = this._uniqueId;
+ }
+ }
+
+ /**
+ * Whether the toggle is checked. Default value : false
+ */
+ @Input()
+ get checked(): boolean {
+ return this._checked;
+ }
+
+ /**
+ * Whether the toggle is disabled. Default value : false
+ */
+ @Input()
+ get disabled() {
+ return this._disabled;
+ }
+
+ /**
+ * Whether the toggle label is visible. Default value : false
+ */
+ @Input()
+ get hiddenLabel() {
+ return this._hiddenLabel;
+ }
+
+ set checked(value: boolean) {
+ if (value !== this.checked) {
+ this._checked = value;
+ this.cdr.markForCheck();
+ }
+ }
+
+ set disabled(value: boolean) {
+ if (value !== this.disabled) {
+ this._disabled = value;
+ this.cdr.markForCheck();
+ }
+ }
+
+ set hiddenLabel(value: boolean) {
+ if (value !== this.hiddenLabel) {
+ this._hiddenLabel = value;
+ this.cdr.markForCheck();
+ }
+ }
+
+ public ngOnInit() {
+ // Set all unique ids for the html elements
+ this.buttonId = `${this.id}-button`;
+ this.ariaLabelledby = `${this.id}-arialabelledby`;
+ }
+
+ public ngAfterViewInit() {
+ this.focusMonitor
+ .monitor(this.buttonElement, false)
+ .subscribe((focusOrigin) => {
+ if (!this.disabled) {
+ this.isFocus = !this.isFocus;
+ this.cdr.markForCheck();
+ }
+ });
+ }
+
+ public ngOnDestroy() {
+ this.focusMonitor.stopMonitoring(this.buttonElement);
+ }
+
+ /**
+ * Whenever there is change on the button html element
+ */
+ public onInteractionEvent(event: Event) {
+ // We always have to stop propagation on the change event.
+ // Otherwise the change event, from the button element, will bubble up and
+ // emit its event object to the `change` output.
+ event.stopPropagation();
+ }
+
+ /**
+ * Whenever there is click event triggered on the toggle
+ */
+ public onButtonClick(event: Event) {
+ event.stopPropagation();
+ this.toggle();
+ this.emitChangeEvent();
+ }
+
+ /**
+ * Emit new values whenever the toggle object has change.
+ */
+ private emitChangeEvent() {
+ if (!this.disabled) {
+ this.change.emit(this.checked);
+ }
+ }
+
+ /**
+ * Set checked value
+ */
+ private toggle() {
+ if (!this.disabled) this.checked = !this.checked;
+ }
+}
diff --git a/projects/angular-ui/src/public-api.ts b/projects/angular-ui/src/public-api.ts
index dfd8518d..5c274c31 100644
--- a/projects/angular-ui/src/public-api.ts
+++ b/projects/angular-ui/src/public-api.ts
@@ -24,4 +24,5 @@ export * from './lib/modal';
export * from './lib/hyperlink';
export * from './lib/dropdown-menu';
export * from './lib/file';
+export * from './lib/toggle/index';
export * from './lib/snack-bar';
diff --git a/projects/storybook-angular/src/stories/Checkbox/Checkbox.stories.ts b/projects/storybook-angular/src/stories/Checkbox/Checkbox.stories.ts
index 64fe3e78..d5ab605b 100644
--- a/projects/storybook-angular/src/stories/Checkbox/Checkbox.stories.ts
+++ b/projects/storybook-angular/src/stories/Checkbox/Checkbox.stories.ts
@@ -156,7 +156,7 @@ export const CheckboxDescriptionHiddenLabel: Story = args => ({
props: args,
template: `
-
+
Label
Est est et dolores dolore sed justo ipsum et sit.
diff --git a/projects/storybook-angular/src/stories/Toggle/Toggle.stories.ts b/projects/storybook-angular/src/stories/Toggle/Toggle.stories.ts
new file mode 100644
index 00000000..8e0f4185
--- /dev/null
+++ b/projects/storybook-angular/src/stories/Toggle/Toggle.stories.ts
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2023 Ville de Montreal. All rights reserved.
+ * Licensed under the MIT license.
+ * See LICENSE file in the project root for full license information.
+ */
+import { moduleMetadata } from '@storybook/angular';
+import { Meta, Story } from '@storybook/angular/types-6-0';
+import {
+ BaoToggleComponent,
+ BaoToggleModule,
+ BaoCommonComponentsModule
+} from 'angular-ui';
+
+const description = `
+## Documentation
+The full documentation of this component is available in the Hochelaga design system documentation under "[Interrupteur](https://zeroheight.com/575tugn0n/p/63ca9f-interrupteur)".
+`;
+
+export default {
+ title: 'Components/Toggle',
+ decorators: [
+ moduleMetadata({
+ declarations: [],
+ imports: [BaoToggleModule, BaoCommonComponentsModule]
+ })
+ ],
+ component: BaoToggleComponent,
+ parameters: {
+ docs: {
+ description: {
+ component: description
+ }
+ }
+ },
+ argTypes: {}
+} as Meta;
+
+const Template: Story = (
+ args: BaoToggleComponent
+) => ({
+ component: BaoToggleComponent,
+ template: `
+
+ {{label}}
+
+ `,
+ props: args
+});
+
+export const Primary = Template.bind({});
+
+Primary.args = {
+ label: 'Label'
+};
+
+export const ToggleSimple: Story = args => ({
+ props: args,
+ template: `
+
+ Interrupteur supplémentaire non fontionnel en position 1
+
+
+ Interrupteur OFF
+
+
+ Interrupteur ON
+
+
+ Interrupteur OFF désactivé
+
+
+ Interrupteur ON désactivé
+
+ `
+});
+
+ToggleSimple.storyName = 'Example with Label';
+ToggleSimple.args = {
+ ...Primary.args
+};
+
+export const ToggleLongLabel: Story = args => ({
+ props: args,
+ template: `
+
+ Interrupteur supplémentaire non fontionnel en position 1
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+
+ `
+});
+
+ToggleLongLabel.storyName = 'Example with long Label';
+ToggleLongLabel.args = {
+ ...Primary.args
+};
+
+export const ToggleHidden: Story = args => ({
+ props: args,
+ template: `
+
+ Interrupteur supplémentaire avec un libellé invisible non fontionnel en position 1
+
+
+ Interrupteur OFF avec un libellé invisible
+
+
+ Interrupteur ON avec un libellé invisible
+
+
+ Interrupteur OFF désactivé avec un libellé invisible
+
+
+ Interrupteur ON désactivé avec un libellé invisible
+
+ `
+});
+
+ToggleHidden.storyName = 'Example with hidden Label';
+ToggleHidden.args = {
+ ...Primary.args
+};