diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index cb45e12228..a6c9e54c57 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,6 +1,7 @@ export * from "./badge"; export * from "./banner"; export * from "./button"; +export * from "./toggle-group"; export * from "./callout"; export * from "./form-field"; export * from "./menu"; diff --git a/libs/components/src/toggle-group/index.ts b/libs/components/src/toggle-group/index.ts new file mode 100644 index 0000000000..bb7dba253e --- /dev/null +++ b/libs/components/src/toggle-group/index.ts @@ -0,0 +1,2 @@ +export * from "./toggle-group.component"; +export * from "./toggle-group.module"; diff --git a/libs/components/src/toggle-group/toggle-group.component.html b/libs/components/src/toggle-group/toggle-group.component.html new file mode 100644 index 0000000000..6dbc743063 --- /dev/null +++ b/libs/components/src/toggle-group/toggle-group.component.html @@ -0,0 +1 @@ + diff --git a/libs/components/src/toggle-group/toggle-group.component.spec.ts b/libs/components/src/toggle-group/toggle-group.component.spec.ts new file mode 100644 index 0000000000..3bce3472e8 --- /dev/null +++ b/libs/components/src/toggle-group/toggle-group.component.spec.ts @@ -0,0 +1,69 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { ToggleGroupModule } from "./toggle-group.module"; +import { ToggleComponent } from "./toggle.component"; + +describe("Button", () => { + let fixture: ComponentFixture; + let testAppComponent: TestApp; + let buttonElements: ToggleComponent[]; + let radioButtons: HTMLInputElement[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ToggleGroupModule], + declarations: [TestApp], + }); + + TestBed.compileComponents(); + fixture = TestBed.createComponent(TestApp); + testAppComponent = fixture.debugElement.componentInstance; + buttonElements = fixture.debugElement + .queryAll(By.css("bit-toggle")) + .map((e) => e.componentInstance); + radioButtons = fixture.debugElement + .queryAll(By.css("input[type=radio]")) + .map((e) => e.nativeElement); + + fixture.detectChanges(); + })); + + it("should select second element when setting selected to second", () => { + testAppComponent.selected = "second"; + fixture.detectChanges(); + + expect(buttonElements[1].selected).toBe(true); + }); + + it("should not select second element when setting selected to third", () => { + testAppComponent.selected = "third"; + fixture.detectChanges(); + + expect(buttonElements[1].selected).toBe(false); + }); + + it("should emit new value when changing selection by clicking on radio button", () => { + testAppComponent.selected = "first"; + fixture.detectChanges(); + + radioButtons[1].click(); + + expect(testAppComponent.selected).toBe("second"); + }); +}); + +@Component({ + selector: "test-app", + template: ` + + First + Second + Third + + `, +}) +class TestApp { + selected?: string; +} diff --git a/libs/components/src/toggle-group/toggle-group.component.ts b/libs/components/src/toggle-group/toggle-group.component.ts new file mode 100644 index 0000000000..adaed4bdb0 --- /dev/null +++ b/libs/components/src/toggle-group/toggle-group.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, HostBinding, Input, Output } from "@angular/core"; + +let nextId = 0; + +@Component({ + selector: "bit-toggle-group", + templateUrl: "./toggle-group.component.html", + preserveWhitespaces: false, +}) +export class ToggleGroupComponent { + private id = nextId++; + name = `bit-toggle-group-${this.id}`; + + @Input() selected?: unknown; + @Output() selectedChange = new EventEmitter(); + + @HostBinding("attr.role") role = "radiogroup"; + @HostBinding("class") classList = ["tw-flex"]; + + onInputInteraction(value: unknown) { + this.selected = value; + this.selectedChange.emit(value); + } +} diff --git a/libs/components/src/toggle-group/toggle-group.module.ts b/libs/components/src/toggle-group/toggle-group.module.ts new file mode 100644 index 0000000000..fe1ce0ec52 --- /dev/null +++ b/libs/components/src/toggle-group/toggle-group.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { BadgeModule } from "../badge"; + +import { ToggleGroupComponent } from "./toggle-group.component"; +import { ToggleComponent } from "./toggle.component"; + +@NgModule({ + imports: [CommonModule, BadgeModule], + exports: [ToggleGroupComponent, ToggleComponent], + declarations: [ToggleGroupComponent, ToggleComponent], +}) +export class ToggleGroupModule {} diff --git a/libs/components/src/toggle-group/toggle-group.stories.ts b/libs/components/src/toggle-group/toggle-group.stories.ts new file mode 100644 index 0000000000..8cc06f8c32 --- /dev/null +++ b/libs/components/src/toggle-group/toggle-group.stories.ts @@ -0,0 +1,54 @@ +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { BadgeModule } from "../badge"; + +import { ToggleGroupComponent } from "./toggle-group.component"; +import { ToggleComponent } from "./toggle.component"; + +export default { + title: "Component Library/Toggle Group", + component: ToggleGroupComponent, + args: { + selected: "all", + }, + decorators: [ + moduleMetadata({ + declarations: [ToggleGroupComponent, ToggleComponent], + imports: [BadgeModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A17157", + }, + }, +} as Meta; + +const Template: Story = (args: ToggleGroupComponent) => ({ + props: args, + template: ` + + + All 3 + + + + Invited + + + + Accepted 2 + + + + Deactivated + + + `, +}); + +export const Default = Template.bind({}); +Default.args = { + selected: "all", +}; diff --git a/libs/components/src/toggle-group/toggle.component.html b/libs/components/src/toggle-group/toggle.component.html new file mode 100644 index 0000000000..471ed5f0c0 --- /dev/null +++ b/libs/components/src/toggle-group/toggle.component.html @@ -0,0 +1,11 @@ + + diff --git a/libs/components/src/toggle-group/toggle.component.spec.ts b/libs/components/src/toggle-group/toggle.component.spec.ts new file mode 100644 index 0000000000..37c2e2ac10 --- /dev/null +++ b/libs/components/src/toggle-group/toggle.component.spec.ts @@ -0,0 +1,71 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { ToggleGroupComponent } from "./toggle-group.component"; +import { ToggleGroupModule } from "./toggle-group.module"; + +describe("Button", () => { + let mockGroupComponent: MockedButtonGroupComponent; + let fixture: ComponentFixture; + let testAppComponent: TestApp; + let radioButton: HTMLInputElement; + + beforeEach(waitForAsync(() => { + mockGroupComponent = new MockedButtonGroupComponent(); + + TestBed.configureTestingModule({ + imports: [ToggleGroupModule], + declarations: [TestApp], + providers: [{ provide: ToggleGroupComponent, useValue: mockGroupComponent }], + }); + + TestBed.compileComponents(); + fixture = TestBed.createComponent(TestApp); + testAppComponent = fixture.debugElement.componentInstance; + radioButton = fixture.debugElement.query(By.css("input[type=radio]")).nativeElement; + })); + + it("should emit value when clicking on radio button", () => { + testAppComponent.value = "value"; + fixture.detectChanges(); + + radioButton.click(); + fixture.detectChanges(); + + expect(mockGroupComponent.onInputInteraction).toHaveBeenCalledWith("value"); + }); + + it("should check radio button when selected matches value", () => { + testAppComponent.value = "value"; + fixture.detectChanges(); + + mockGroupComponent.selected = "value"; + fixture.detectChanges(); + + expect(radioButton.checked).toBe(true); + }); + + it("should not check radio button when selected does not match value", () => { + testAppComponent.value = "value"; + fixture.detectChanges(); + + mockGroupComponent.selected = "nonMatchingValue"; + fixture.detectChanges(); + + expect(radioButton.checked).toBe(false); + }); +}); + +class MockedButtonGroupComponent implements Partial { + onInputInteraction = jest.fn(); + selected = null; +} + +@Component({ + selector: "test-app", + template: ` Element`, +}) +class TestApp { + value?: string; +} diff --git a/libs/components/src/toggle-group/toggle.component.ts b/libs/components/src/toggle-group/toggle.component.ts new file mode 100644 index 0000000000..557ab38b38 --- /dev/null +++ b/libs/components/src/toggle-group/toggle.component.ts @@ -0,0 +1,80 @@ +import { HostBinding, Component, Input } from "@angular/core"; + +import { ToggleGroupComponent } from "./toggle-group.component"; + +let nextId = 0; + +@Component({ + selector: "bit-toggle", + templateUrl: "./toggle.component.html", + preserveWhitespaces: false, +}) +export class ToggleComponent { + id = nextId++; + + @Input() value?: string; + + constructor(private groupComponent: ToggleGroupComponent) {} + + @HostBinding("tabIndex") tabIndex = "-1"; + @HostBinding("class") classList = ["tw-group"]; + + get name() { + return this.groupComponent.name; + } + + get selected() { + return this.groupComponent.selected === this.value; + } + + get inputClasses() { + return ["tw-peer", "tw-appearance-none", "tw-outline-none"]; + } + + get labelClasses() { + return [ + "!tw-font-semibold", + "tw-transition", + "tw-text-center", + "tw-border-text-muted", + "!tw-text-muted", + "tw-border-solid", + "tw-border-y", + "tw-border-r", + "tw-border-l-0", + "tw-cursor-pointer", + "group-first-of-type:tw-border-l", + "group-first-of-type:tw-rounded-l", + "group-last-of-type:tw-rounded-r", + + "peer-focus:tw-outline-none", + "peer-focus:tw-ring", + "peer-focus:tw-ring-offset-2", + "peer-focus:tw-ring-primary-500", + "peer-focus:tw-z-10", + "peer-focus:tw-bg-primary-500", + "peer-focus:tw-border-primary-500", + "peer-focus:!tw-text-contrast", + + "hover:tw-no-underline", + "hover:tw-bg-text-muted", + "hover:tw-border-text-muted", + "hover:!tw-text-contrast", + + "peer-checked:tw-bg-primary-500", + "peer-checked:tw-border-primary-500", + "peer-checked:!tw-text-contrast", + "tw-py-1.5", + "tw-px-3", + + // Fix for badge being pushed slightly lower when inside a button. + // Insipired by bootstrap, which does the same. + "[&>[bitBadge]]:tw-relative", + "[&>[bitBadge]]:-tw-top-[1px]", + ]; + } + + onInputInteraction() { + this.groupComponent.onInputInteraction(this.value); + } +}