[Cl-10] Button group (#3031)

* chore: setup initial bit-button-group using bitButton as template

* feat: working radio group with preliminary styling

* chore: cleanup

* feat: implement proper basic styling

* feat: implement focus handling and keyboard navigation

* feat: implement size support

* feat: add labeling support

* feat: add input for button selection

* feat: implement output handler on radio button interaction

* feat: implement internal input/output seletion handling

* feat: add external input support

* feat: add external output support

* chore: simplify storybook example a bit

* fix: module imports

* refactor: simplify both components

* feat: remove size

* chore: rename button-group to toggle-group

* chore: rename toggle-group-element to toggle-group-button

* chore: update story example

* fix: compatibility with web vault

* fix: imports in tests after rename

* fix: remove unnecessary inject decorator

* fix: clarify field names and html tags

* feat: add badge centering fix

* feat: set pointer cursor on label

* chore: comment on special css rules

* chore: remove focusable option from button

* Update libs/components/src/toggle-group/toggle-group-button.component.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* chore: rename to `bit-toggle`

* fix: remove unecessary aria label function

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu 2022-07-18 14:25:37 +02:00 committed by GitHub
parent 5284072ff1
commit cd5aef1757
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 0 deletions

View File

@ -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";

View File

@ -0,0 +1,2 @@
export * from "./toggle-group.component";
export * from "./toggle-group.module";

View File

@ -0,0 +1 @@
<ng-content></ng-content>

View File

@ -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<TestApp>;
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: `
<bit-toggle-group [(selected)]="selected">
<bit-toggle value="first">First</bit-toggle>
<bit-toggle value="second">Second</bit-toggle>
<bit-toggle value="third">Third</bit-toggle>
</bit-toggle-group>
`,
})
class TestApp {
selected?: string;
}

View File

@ -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<unknown>();
@HostBinding("attr.role") role = "radiogroup";
@HostBinding("class") classList = ["tw-flex"];
onInputInteraction(value: unknown) {
this.selected = value;
this.selectedChange.emit(value);
}
}

View File

@ -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 {}

View File

@ -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<ToggleGroupComponent> = (args: ToggleGroupComponent) => ({
props: args,
template: `
<bit-toggle-group [(selected)]="selected" aria-label="People list filter">
<bit-toggle value="all">
All <span bitBadge badgeType="info">3</span>
</bit-toggle>
<bit-toggle value="invited">
Invited
</bit-toggle>
<bit-toggle value="accepted">
Accepted <span bitBadge badgeType="info">2</span>
</bit-toggle>
<bit-toggle value="deactivated">
Deactivated
</bit-toggle>
</bit-toggle-group>
`,
});
export const Default = Template.bind({});
Default.args = {
selected: "all",
};

View File

@ -0,0 +1,11 @@
<input
type="radio"
id="bit-toggle-{{ id }}"
[name]="name"
[ngClass]="inputClasses"
[checked]="selected"
(change)="onInputInteraction()"
/>
<label for="bit-toggle-{{ id }}" [ngClass]="labelClasses">
<ng-content></ng-content>
</label>

View File

@ -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<TestApp>;
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<ToggleGroupComponent> {
onInputInteraction = jest.fn();
selected = null;
}
@Component({
selector: "test-app",
template: ` <bit-toggle [value]="value">Element</bit-toggle>`,
})
class TestApp {
value?: string;
}

View File

@ -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);
}
}