mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +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 "./badge";
|
||||||
export * from "./banner";
|
export * from "./banner";
|
||||||
export * from "./button";
|
export * from "./button";
|
||||||
|
export * from "./toggle-group";
|
||||||
export * from "./callout";
|
export * from "./callout";
|
||||||
export * from "./form-field";
|
export * from "./form-field";
|
||||||
export * from "./menu";
|
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