Browse Source
* 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>pull/3129/head
10 changed files with 327 additions and 0 deletions
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export * from "./toggle-group.component"; |
||||
export * from "./toggle-group.module"; |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
<ng-content></ng-content> |
||||
@ -0,0 +1,69 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -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 {} |
||||
@ -0,0 +1,54 @@
@@ -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", |
||||
}; |
||||
@ -0,0 +1,11 @@
@@ -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> |
||||
@ -0,0 +1,71 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,80 @@
@@ -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