mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[CL-946] Migrate ToggleGroup to OnPush (#17718)
Migrates the ToggleGroup and Toggle components to use OnPush.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, signal, WritableSignal } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
@@ -30,31 +30,29 @@ describe("Button", () => {
|
||||
});
|
||||
|
||||
it("should select second element when setting selected to second", () => {
|
||||
testAppComponent.selected = "second";
|
||||
testAppComponent.selected.set("second");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElements[1].selected).toBe(true);
|
||||
expect(buttonElements[1].selected()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not select second element when setting selected to third", () => {
|
||||
testAppComponent.selected = "third";
|
||||
testAppComponent.selected.set("third");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(buttonElements[1].selected).toBe(false);
|
||||
expect(buttonElements[1].selected()).toBe(false);
|
||||
});
|
||||
|
||||
it("should emit new value when changing selection by clicking on radio button", () => {
|
||||
testAppComponent.selected = "first";
|
||||
testAppComponent.selected.set("first");
|
||||
fixture.detectChanges();
|
||||
|
||||
radioButtons[1].click();
|
||||
|
||||
expect(testAppComponent.selected).toBe("second");
|
||||
expect(testAppComponent.selected()).toBe("second");
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "test-app",
|
||||
template: `
|
||||
@@ -65,7 +63,8 @@ describe("Button", () => {
|
||||
</bit-toggle-group>
|
||||
`,
|
||||
imports: [ToggleGroupModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestAppComponent {
|
||||
selected?: string;
|
||||
readonly selected: WritableSignal<string | undefined> = signal(undefined);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
import {
|
||||
booleanAttribute,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Output,
|
||||
computed,
|
||||
input,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-toggle-group",
|
||||
templateUrl: "./toggle-group.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
role: "radiogroup",
|
||||
"[class]": "classlist()",
|
||||
},
|
||||
})
|
||||
export class ToggleGroupComponent<TValue = unknown> {
|
||||
private id = nextId++;
|
||||
name = `bit-toggle-group-${this.id}`;
|
||||
private readonly id = nextId++;
|
||||
|
||||
readonly name = `bit-toggle-group-${this.id}`;
|
||||
|
||||
/**
|
||||
* Whether the toggle group should take up the full width of its container.
|
||||
* When true, each toggle button will be equally sized to fill the available space.
|
||||
*/
|
||||
readonly fullWidth = input<boolean, unknown>(undefined, { transform: booleanAttribute });
|
||||
readonly selected = model<TValue>();
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() selectedChange = new EventEmitter<TValue>();
|
||||
|
||||
@HostBinding("attr.role") role = "radiogroup";
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
return ["tw-flex"].concat(this.fullWidth() ? ["tw-w-full", "[&>*]:tw-flex-1"] : []);
|
||||
}
|
||||
/**
|
||||
* The selected value in the toggle group.
|
||||
*/
|
||||
readonly selected = model<TValue>();
|
||||
|
||||
protected readonly classlist = computed(() =>
|
||||
["tw-flex"].concat(this.fullWidth() ? ["tw-w-full", "[&>*]:tw-flex-1"] : []),
|
||||
);
|
||||
|
||||
onInputInteraction(value: TValue) {
|
||||
this.selected.set(value);
|
||||
this.selectedChange.emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<input
|
||||
type="radio"
|
||||
id="bit-toggle-{{ id }}"
|
||||
[id]="id"
|
||||
[name]="name"
|
||||
[ngClass]="inputClasses"
|
||||
[checked]="selected"
|
||||
(change)="onInputInteraction()"
|
||||
[class]="inputClasses"
|
||||
[checked]="selected()"
|
||||
(change)="handleInputChange()"
|
||||
/>
|
||||
<label for="bit-toggle-{{ id }}" [ngClass]="labelClasses" [title]="labelTitle()">
|
||||
<label [for]="id" [class]="labelClasses" [title]="labelTitle()">
|
||||
<span class="tw-line-clamp-2 tw-break-words" #labelContent>
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
<span class="tw-shrink-0" #bitBadgeContainer [hidden]="!bitBadgeContainerHasChidlren()">
|
||||
<span class="tw-shrink-0" [hidden]="!hasBadge()">
|
||||
<ng-content select="[bitBadge]"></ng-content>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, DebugElement } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, DebugElement, signal } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
import { BadgeModule } from "../badge";
|
||||
|
||||
import { ToggleGroupComponent } from "./toggle-group.component";
|
||||
import { ToggleGroupModule } from "./toggle-group.module";
|
||||
|
||||
@@ -45,8 +47,45 @@ describe("Toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
describe("Toggle with badge content", () => {
|
||||
let fixtureWithBadge: ComponentFixture<TestComponentWithBadgeComponent>;
|
||||
let badgeContainers: DebugElement[];
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TestComponentWithBadgeComponent],
|
||||
});
|
||||
|
||||
await TestBed.compileComponents();
|
||||
fixtureWithBadge = TestBed.createComponent(TestComponentWithBadgeComponent);
|
||||
fixtureWithBadge.detectChanges();
|
||||
badgeContainers = fixtureWithBadge.debugElement.queryAll(By.css(".tw-shrink-0"));
|
||||
});
|
||||
|
||||
it("should hide badge container when no badge content is projected", () => {
|
||||
// First toggle has no badge
|
||||
expect(badgeContainers[0].nativeElement.hidden).toBe(true);
|
||||
|
||||
// Second toggle has a badge
|
||||
expect(badgeContainers[1].nativeElement.hidden).toBe(false);
|
||||
|
||||
// Third toggle has no badge
|
||||
expect(badgeContainers[2].nativeElement.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("should show badge container when badge content is projected", () => {
|
||||
const badgeElement = fixtureWithBadge.debugElement.query(By.css("[bitBadge]"));
|
||||
expect(badgeElement).toBeTruthy();
|
||||
expect(badgeElement.nativeElement.textContent.trim()).toBe("2");
|
||||
});
|
||||
|
||||
it("should render badge content correctly", () => {
|
||||
const badges = fixtureWithBadge.debugElement.queryAll(By.css("[bitBadge]"));
|
||||
expect(badges.length).toBe(1);
|
||||
expect(badges[0].nativeElement.textContent.trim()).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: "test-component",
|
||||
template: `
|
||||
@@ -56,7 +95,24 @@ describe("Toggle", () => {
|
||||
</bit-toggle-group>
|
||||
`,
|
||||
imports: [ToggleGroupModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestComponent {
|
||||
selected = 0;
|
||||
readonly selected = signal(0);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "test-component-with-badge",
|
||||
template: `
|
||||
<bit-toggle-group [(selected)]="selected">
|
||||
<bit-toggle [value]="0">Zero</bit-toggle>
|
||||
<bit-toggle [value]="1">One <span bitBadge variant="info">2</span></bit-toggle>
|
||||
<bit-toggle [value]="2">Two</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
`,
|
||||
imports: [ToggleGroupModule, BadgeModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestComponentWithBadgeComponent {
|
||||
readonly selected = signal(0);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,69 @@
|
||||
import { NgClass } from "@angular/common";
|
||||
import {
|
||||
AfterContentChecked,
|
||||
AfterViewInit,
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChild,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
signal,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { BadgeComponent } from "../badge";
|
||||
|
||||
import { ToggleGroupComponent } from "./toggle-group.component";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-toggle",
|
||||
templateUrl: "./toggle.component.html",
|
||||
imports: [NgClass],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
tabindex: "-1",
|
||||
"[class]": "hostClasses",
|
||||
},
|
||||
})
|
||||
export class ToggleComponent<TValue> implements AfterContentChecked, AfterViewInit {
|
||||
id = nextId++;
|
||||
export class ToggleComponent<TValue> {
|
||||
protected readonly id = "bit-toggle-" + nextId++;
|
||||
|
||||
private readonly groupComponent = inject(ToggleGroupComponent<TValue>);
|
||||
|
||||
readonly value = input.required<TValue>();
|
||||
readonly labelContent = viewChild<ElementRef<HTMLSpanElement>>("labelContent");
|
||||
readonly bitBadgeContainer = viewChild<ElementRef<HTMLSpanElement>>("bitBadgeContainer");
|
||||
protected readonly labelContent = viewChild<ElementRef<HTMLSpanElement>>("labelContent");
|
||||
protected readonly badgeElement = contentChild(BadgeComponent);
|
||||
protected readonly hasBadge = computed(() => !!this.badgeElement());
|
||||
|
||||
constructor(private groupComponent: ToggleGroupComponent<TValue>) {}
|
||||
|
||||
@HostBinding("tabIndex") tabIndex = "-1";
|
||||
@HostBinding("class") classList = ["tw-group/toggle", "tw-flex", "tw-min-w-16"];
|
||||
|
||||
protected readonly bitBadgeContainerHasChidlren = signal(false);
|
||||
protected readonly labelTitle = signal<string | null>(null);
|
||||
|
||||
get name() {
|
||||
return this.groupComponent.name;
|
||||
constructor() {
|
||||
// Set label title after view is initialized
|
||||
afterNextRender(() => {
|
||||
const labelText = this.labelContent()?.nativeElement.innerText;
|
||||
if (labelText) {
|
||||
this.labelTitle.set(labelText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this.groupComponent.selected() === this.value();
|
||||
protected readonly name = this.groupComponent.name;
|
||||
readonly selected = computed(() => this.groupComponent.selected() === this.value());
|
||||
|
||||
protected handleInputChange() {
|
||||
this.groupComponent.onInputInteraction(this.value());
|
||||
}
|
||||
|
||||
get inputClasses() {
|
||||
return ["tw-peer/toggle-input", "tw-appearance-none", "tw-outline-none"];
|
||||
}
|
||||
protected readonly hostClasses = ["tw-group/toggle", "tw-flex", "tw-min-w-16"];
|
||||
|
||||
get labelClasses() {
|
||||
return [
|
||||
protected readonly inputClasses = [
|
||||
"tw-peer/toggle-input",
|
||||
"tw-appearance-none",
|
||||
"tw-outline-none",
|
||||
];
|
||||
|
||||
protected readonly labelClasses = [
|
||||
"tw-h-full",
|
||||
"tw-w-full",
|
||||
"tw-flex",
|
||||
@@ -92,22 +106,4 @@ export class ToggleComponent<TValue> implements AfterContentChecked, AfterViewIn
|
||||
// Fix for bootstrap styles that add bottom margin
|
||||
"!tw-mb-0",
|
||||
];
|
||||
}
|
||||
|
||||
onInputInteraction() {
|
||||
this.groupComponent.onInputInteraction(this.value());
|
||||
}
|
||||
|
||||
ngAfterContentChecked() {
|
||||
this.bitBadgeContainerHasChidlren.set(
|
||||
(this.bitBadgeContainer()?.nativeElement.childElementCount ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const labelText = this.labelContent()?.nativeElement.innerText;
|
||||
if (labelText) {
|
||||
this.labelTitle.set(labelText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user