mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[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:
parent
5284072ff1
commit
cd5aef1757
@ -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";
|
||||
|
2
libs/components/src/toggle-group/index.ts
Normal file
2
libs/components/src/toggle-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./toggle-group.component";
|
||||
export * from "./toggle-group.module";
|
@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
@ -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;
|
||||
}
|
24
libs/components/src/toggle-group/toggle-group.component.ts
Normal file
24
libs/components/src/toggle-group/toggle-group.component.ts
Normal 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);
|
||||
}
|
||||
}
|
14
libs/components/src/toggle-group/toggle-group.module.ts
Normal file
14
libs/components/src/toggle-group/toggle-group.module.ts
Normal 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 {}
|
54
libs/components/src/toggle-group/toggle-group.stories.ts
Normal file
54
libs/components/src/toggle-group/toggle-group.stories.ts
Normal 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",
|
||||
};
|
11
libs/components/src/toggle-group/toggle.component.html
Normal file
11
libs/components/src/toggle-group/toggle.component.html
Normal 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>
|
71
libs/components/src/toggle-group/toggle.component.spec.ts
Normal file
71
libs/components/src/toggle-group/toggle.component.spec.ts
Normal 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;
|
||||
}
|
80
libs/components/src/toggle-group/toggle.component.ts
Normal file
80
libs/components/src/toggle-group/toggle.component.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user