mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00:00
[CL-820] Switch component (#16216)
* Add switch component * fix focus state * updating stories * add switch role * updated story docs code examples * Add max length and long label story * Add disabled reason text * fix hint spacing * support rtl thumb transform * use correct input syntax. assign value to template variable * remove pointer when disabled * Show disabled text as title if it exists * add basic switch component tests * keep switch top aligned * move switch back to right side of label * add max width to label and hint * updated switch story docs * fix story html formatting * better comment about which are ControlValueAccessor functions * add JSDoc comment about model signals * update methods to mirror search input format * fix notify function type * fix typo * throw error if label is not provided * add hover and focus states * add label to failing spec * import bit-label
This commit is contained in:
61
libs/components/src/switch/switch.component.html
Normal file
61
libs/components/src/switch/switch.component.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@let disabledText = disabledReasonText();
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="tw-rounded-md tw-flex tw-flex-col [&:has(input:focus-visible)]:tw-ring-2 [&:has(input:focus-visible)]:tw-ring-offset-2 [&:has(input:focus-visible)]:tw-ring-primary-600"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
[attr.for]="inputId"
|
||||||
|
class="tw-inline-flex tw-gap-2 tw-justify-between tw-group/switch-label"
|
||||||
|
[ngClass]="{
|
||||||
|
'tw-cursor-default': disabled(),
|
||||||
|
'tw-cursor-pointer': !disabled(),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
bitTypography="body2"
|
||||||
|
class="tw-block [&_*]:tw-whitespace-normal tw-max-w-[60ch]"
|
||||||
|
[ngClass]="{ 'tw-text-muted tw-pointer-events-none': disabled() }"
|
||||||
|
>
|
||||||
|
<ng-content select="bit-label"></ng-content>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="tw-relative tw-w-9 tw-shrink-0 tw-h-[1.375rem] tw-rounded-full tw-relative after:tw-transition-[background-color] after:tw-absolute after:tw-inset-0 after:tw-rounded-full after:tw-size-full"
|
||||||
|
[ngClass]="{
|
||||||
|
'tw-bg-secondary-100': disabled(),
|
||||||
|
'tw-bg-primary-600 [&:has(input:focus-visible)]:after:tw-bg-primary-700 group-hover/switch-label:after:tw-bg-primary-700':
|
||||||
|
selected() && !disabled(),
|
||||||
|
'tw-bg-secondary-300 [&:has(input:focus-visible)]:after:tw-bg-hover-default group-hover/switch-label:after:tw-bg-hover-default':
|
||||||
|
!selected() && !disabled(),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
[id]="inputId"
|
||||||
|
[checked]="selected()"
|
||||||
|
[attr.aria-disabled]="disabled()"
|
||||||
|
(change)="onInputChange($event)"
|
||||||
|
class="tw-sr-only"
|
||||||
|
[attr.aria-describedby]="describedByIds()"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="tw-absolute tw-z-10 tw-block tw-size-[1.125rem] tw-top-[2px] tw-start-[2px] tw-bg-text-alt2 tw-rounded-full tw-shadow-md tw-transform tw-transition-transform"
|
||||||
|
[ngClass]="{
|
||||||
|
'tw-translate-x-[calc(theme(spacing.9)_-_(1.125rem_+_4px))] rtl:-tw-translate-x-[calc(theme(spacing.9)_-_(1.125rem_+_4px))]':
|
||||||
|
selected(),
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="[&_bit-hint]:tw-mt-0 tw-max-w-[60ch] tw-leading-none">
|
||||||
|
<ng-content select="bit-hint" ngProjectAs="bit-hint"></ng-content>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (disabledText && disabled()) {
|
||||||
|
<div [attr.id]="disabledReasonTextId" class="tw-sr-only">
|
||||||
|
{{ disabledText }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
98
libs/components/src/switch/switch.component.spec.ts
Normal file
98
libs/components/src/switch/switch.component.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
|
|
||||||
|
import { BitLabel } from "../form-control/label.component";
|
||||||
|
|
||||||
|
import { SwitchComponent } from "./switch.component";
|
||||||
|
import { SwitchModule } from "./switch.module";
|
||||||
|
|
||||||
|
describe("SwitchComponent", () => {
|
||||||
|
let fixture: ComponentFixture<TestHostComponent>;
|
||||||
|
let switchComponent: SwitchComponent;
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: "test-host",
|
||||||
|
imports: [FormsModule, BitLabel, ReactiveFormsModule, SwitchModule],
|
||||||
|
template: `
|
||||||
|
<form [formGroup]="formObj">
|
||||||
|
<bit-switch formControlName="switch">
|
||||||
|
<bit-label>Element</bit-label>
|
||||||
|
</bit-switch>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class TestHostComponent {
|
||||||
|
formObj = new FormGroup({
|
||||||
|
switch: new FormControl(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TestHostComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TestHostComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const debugSwitch = fixture.debugElement.query(By.directive(SwitchComponent));
|
||||||
|
switchComponent = debugSwitch.componentInstance;
|
||||||
|
inputEl = debugSwitch.nativeElement.querySelector("input[type=checkbox]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update checked attribute when selected changes programmatically", () => {
|
||||||
|
expect(inputEl.checked).toBe(false);
|
||||||
|
|
||||||
|
switchComponent.writeValue(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(inputEl.checked).toBe(true);
|
||||||
|
|
||||||
|
switchComponent.writeValue(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(inputEl.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update checked attribute when switch is clicked", () => {
|
||||||
|
expect(inputEl.checked).toBe(false);
|
||||||
|
|
||||||
|
inputEl.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(inputEl.checked).toBe(true);
|
||||||
|
|
||||||
|
inputEl.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(inputEl.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update checked when selected input changes outside of a form", async () => {
|
||||||
|
@Component({
|
||||||
|
selector: "test-selected-host",
|
||||||
|
template: `<bit-switch [selected]="checked"><bit-label>Element</bit-label></bit-switch>`,
|
||||||
|
standalone: true,
|
||||||
|
imports: [SwitchComponent, BitLabel],
|
||||||
|
})
|
||||||
|
class TestSelectedHostComponent {
|
||||||
|
checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostFixture = TestBed.createComponent(TestSelectedHostComponent);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
const switchDebug = hostFixture.debugElement.query(By.directive(SwitchComponent));
|
||||||
|
const input = switchDebug.nativeElement.querySelector('input[type="checkbox"]');
|
||||||
|
|
||||||
|
expect(input.checked).toBe(false);
|
||||||
|
|
||||||
|
hostFixture.componentInstance.checked = true;
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
expect(input.checked).toBe(true);
|
||||||
|
|
||||||
|
hostFixture.componentInstance.checked = false;
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
expect(input.checked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
libs/components/src/switch/switch.component.ts
Normal file
132
libs/components/src/switch/switch.component.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { NgClass } from "@angular/common";
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
contentChild,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
model,
|
||||||
|
AfterViewInit,
|
||||||
|
} from "@angular/core";
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||||
|
|
||||||
|
import { AriaDisableDirective } from "../a11y";
|
||||||
|
import { FormControlModule } from "../form-control/form-control.module";
|
||||||
|
import { BitHintComponent } from "../form-control/hint.component";
|
||||||
|
import { BitLabel } from "../form-control/label.component";
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch component for toggling between two states. Switch actions are meant to take place immediately and are not to be used in a form where saving/submiting actions are required.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: "bit-switch",
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: SwitchComponent,
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
templateUrl: "switch.component.html",
|
||||||
|
imports: [FormControlModule, NgClass],
|
||||||
|
host: {
|
||||||
|
"[id]": "this.id()",
|
||||||
|
"[attr.aria-disabled]": "this.disabled()",
|
||||||
|
"[attr.title]": "this.disabled() ? this.disabledReasonText() : null",
|
||||||
|
},
|
||||||
|
hostDirectives: [AriaDisableDirective],
|
||||||
|
})
|
||||||
|
export class SwitchComponent implements ControlValueAccessor, AfterViewInit {
|
||||||
|
private el = inject(ElementRef<HTMLButtonElement>);
|
||||||
|
private readonly label = contentChild.required(BitLabel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model signal for selected state binding when used outside of a form
|
||||||
|
*/
|
||||||
|
protected selected = model(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model signal for disabled binding when used outside of a form
|
||||||
|
*/
|
||||||
|
protected disabled = model(false);
|
||||||
|
protected disabledReasonText = input<string | null>(null);
|
||||||
|
|
||||||
|
private hintComponent = contentChild<BitHintComponent>(BitHintComponent);
|
||||||
|
|
||||||
|
private disabledReasonTextId = `bit-switch-disabled-text-${nextId++}`;
|
||||||
|
|
||||||
|
private describedByIds = computed(() => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
if (this.disabledReasonText() && this.disabled()) {
|
||||||
|
ids.push(this.disabledReasonTextId);
|
||||||
|
} else {
|
||||||
|
const hintId = this.hintComponent()?.id;
|
||||||
|
|
||||||
|
if (hintId) {
|
||||||
|
ids.push(hintId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ControlValueAccessor functions
|
||||||
|
private notifyOnChange: (value: boolean) => void = () => {};
|
||||||
|
private notifyOnTouch: () => void = () => {};
|
||||||
|
|
||||||
|
writeValue(value: boolean): void {
|
||||||
|
this.selected.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(value: boolean): void {
|
||||||
|
this.selected.set(value);
|
||||||
|
|
||||||
|
if (this.notifyOnChange != undefined) {
|
||||||
|
this.notifyOnChange(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouch() {
|
||||||
|
if (this.notifyOnTouch != undefined) {
|
||||||
|
this.notifyOnTouch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (value: boolean) => void): void {
|
||||||
|
this.notifyOnChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.notifyOnTouch = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean) {
|
||||||
|
this.disabled.set(isDisabled);
|
||||||
|
}
|
||||||
|
// end ControlValueAccessor functions
|
||||||
|
|
||||||
|
readonly id = input(`bit-switch-${nextId++}`);
|
||||||
|
|
||||||
|
protected onInputChange(event: Event) {
|
||||||
|
const checked = (event.target as HTMLInputElement).checked;
|
||||||
|
this.onChange(checked);
|
||||||
|
this.onTouch();
|
||||||
|
}
|
||||||
|
|
||||||
|
get inputId() {
|
||||||
|
return `${this.id()}-input`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
if (!this.label()) {
|
||||||
|
// This is only here so Angular throws a compilation error if no label is provided.
|
||||||
|
// the `this.label()` value must try to be accessed for the required content child check to throw
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("No label component provided. <bit-switch> must be used with a <bit-label>.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
libs/components/src/switch/switch.mdx
Normal file
36
libs/components/src/switch/switch.mdx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||||
|
|
||||||
|
import * as stories from "./switch.stories";
|
||||||
|
|
||||||
|
<Meta of={stories} />
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SwitchModule } from "@bitwarden/components";
|
||||||
|
```
|
||||||
|
|
||||||
|
<Title />
|
||||||
|
<Description />
|
||||||
|
|
||||||
|
NOTE: The switch component will span 100% of the width of its container. These stories have a
|
||||||
|
container with a `max-width` of 600px
|
||||||
|
|
||||||
|
<Primary />
|
||||||
|
<Controls />
|
||||||
|
|
||||||
|
## Stories
|
||||||
|
|
||||||
|
### Default
|
||||||
|
|
||||||
|
<Canvas of={stories.Default} />
|
||||||
|
|
||||||
|
### Used with a from
|
||||||
|
|
||||||
|
<Canvas of={stories.WithForm} />
|
||||||
|
|
||||||
|
### With Long Label
|
||||||
|
|
||||||
|
<Canvas of={stories.WithLongLabel} />
|
||||||
|
|
||||||
|
### Disabled
|
||||||
|
|
||||||
|
<Canvas of={stories.Disabled} />
|
||||||
9
libs/components/src/switch/switch.module.ts
Normal file
9
libs/components/src/switch/switch.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { SwitchComponent } from "./switch.component";
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [SwitchComponent],
|
||||||
|
exports: [SwitchComponent],
|
||||||
|
})
|
||||||
|
export class SwitchModule {}
|
||||||
138
libs/components/src/switch/switch.stories.ts
Normal file
138
libs/components/src/switch/switch.stories.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup } from "@angular/forms";
|
||||||
|
import { Meta, moduleMetadata, StoryObj, componentWrapperDecorator } from "@storybook/angular";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
|
||||||
|
import { FormControlModule } from "../form-control";
|
||||||
|
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||||
|
|
||||||
|
import { SwitchComponent } from "./switch.component";
|
||||||
|
|
||||||
|
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Component Library/Form/Switch",
|
||||||
|
component: SwitchComponent,
|
||||||
|
decorators: [
|
||||||
|
componentWrapperDecorator((story) => {
|
||||||
|
return /* HTML */ `<div class="tw-max-w-[600px] ">${story}</div>`;
|
||||||
|
}),
|
||||||
|
moduleMetadata({
|
||||||
|
imports: [FormsModule, ReactiveFormsModule, SwitchComponent, FormControlModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: I18nService,
|
||||||
|
useFactory: () => {
|
||||||
|
return new I18nMockService({
|
||||||
|
required: "required",
|
||||||
|
inputRequired: "Input is required.",
|
||||||
|
inputEmail: "Input is not an email-address.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
argTypes: {
|
||||||
|
disabled: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Model signal for disabled binding when used outside of a form",
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Model signal for selected state binding when used outside of a form",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
design: {
|
||||||
|
type: "figma",
|
||||||
|
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/8UUiry70QWI1VjILxo75GS/Tailwind-Component-Library?m=auto&node-id=30341-13313&t=83S7fjfIUxQJsM2r-1",
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
// exclude ControlAccessorValue methods
|
||||||
|
exclude: ["registerOnChange", "registerOnTouched", "setDisabledState", "writeValue"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Meta<SwitchComponent>;
|
||||||
|
|
||||||
|
type Story = StoryObj<SwitchComponent & { disabled?: boolean; selected?: boolean }>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: {
|
||||||
|
formObj: new FormGroup({
|
||||||
|
switch: new FormControl(0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
template: /* HTML */ `
|
||||||
|
<bit-switch ${formatArgsForCodeSnippet<SwitchComponent>(args)}>
|
||||||
|
<bit-label>Example switch</bit-label>
|
||||||
|
<bit-hint>This is a hint for the switch</bit-hint>
|
||||||
|
</bit-switch>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
args: {
|
||||||
|
disabled: false,
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithLongLabel: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: {
|
||||||
|
formObj: new FormGroup({
|
||||||
|
switch: new FormControl(0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
template: /* HTML */ `
|
||||||
|
<bit-switch ${formatArgsForCodeSnippet<SwitchComponent>(args)}>
|
||||||
|
<bit-label>
|
||||||
|
This example switch has a super long label. This is not recommended. Switch labels should
|
||||||
|
be clear and concise. They should tell the user what turning on the switch will do.
|
||||||
|
</bit-label>
|
||||||
|
<bit-hint>This is a hint for the switch</bit-hint>
|
||||||
|
</bit-switch>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
args: {
|
||||||
|
disabled: false,
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithForm: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: {
|
||||||
|
formObj: new FormGroup({
|
||||||
|
switch: new FormControl(0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
template: /* HTML */ `
|
||||||
|
<form [formGroup]="formObj">
|
||||||
|
<bit-switch formControlName="switch" ${formatArgsForCodeSnippet<SwitchComponent>(args)}>
|
||||||
|
<bit-label>Example switch</bit-label>
|
||||||
|
<bit-hint>This is a hint for the switch</bit-hint>
|
||||||
|
</bit-switch>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: /* HTML */ `
|
||||||
|
<bit-switch
|
||||||
|
disabledReasonText="Switch disabled because I am not allowed to change it"
|
||||||
|
${formatArgsForCodeSnippet<SwitchComponent>(args)}
|
||||||
|
>
|
||||||
|
<bit-label>Example switch</bit-label>
|
||||||
|
<bit-hint>This is a hint for the switch</bit-hint>
|
||||||
|
</bit-switch>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
args: {
|
||||||
|
disabled: true,
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user