1
0
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:
Oscar Hinton
2025-12-05 13:06:03 +01:00
committed by GitHub
parent 110c955cff
commit ff7b625851
5 changed files with 178 additions and 122 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,113 +1,109 @@
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());
get inputClasses() {
return ["tw-peer/toggle-input", "tw-appearance-none", "tw-outline-none"];
}
get labelClasses() {
return [
"tw-h-full",
"tw-w-full",
"tw-flex",
"tw-items-center",
"tw-justify-center",
"tw-gap-1.5",
"!tw-font-medium",
"tw-leading-5",
"tw-transition",
"tw-text-center",
"tw-text-sm",
"tw-border-primary-600",
"!tw-text-primary-600",
"tw-border-solid",
"tw-border-y",
"tw-border-r",
"tw-border-l-0",
"tw-cursor-pointer",
"hover:tw-bg-hover-default",
"group-first-of-type/toggle:tw-border-l",
"group-first-of-type/toggle:tw-rounded-s-full",
"group-last-of-type/toggle:tw-rounded-e-full",
"peer-focus-visible/toggle-input:tw-outline-none",
"peer-focus-visible/toggle-input:tw-ring",
"peer-focus-visible/toggle-input:tw-ring-offset-2",
"peer-focus-visible/toggle-input:tw-ring-primary-600",
"peer-focus-visible/toggle-input:tw-z-10",
"peer-focus-visible/toggle-input:tw-bg-primary-600",
"peer-focus-visible/toggle-input:tw-border-primary-600",
"peer-focus-visible/toggle-input:!tw-text-contrast",
"peer-checked/toggle-input:tw-bg-primary-600",
"peer-checked/toggle-input:tw-border-primary-600",
"peer-checked/toggle-input:!tw-text-contrast",
"tw-py-1.5",
"tw-px-3",
// Fix for bootstrap styles that add bottom margin
"!tw-mb-0",
];
}
onInputInteraction() {
protected handleInputChange() {
this.groupComponent.onInputInteraction(this.value());
}
ngAfterContentChecked() {
this.bitBadgeContainerHasChidlren.set(
(this.bitBadgeContainer()?.nativeElement.childElementCount ?? 0) > 0,
);
}
protected readonly hostClasses = ["tw-group/toggle", "tw-flex", "tw-min-w-16"];
ngAfterViewInit() {
const labelText = this.labelContent()?.nativeElement.innerText;
if (labelText) {
this.labelTitle.set(labelText);
}
}
protected readonly inputClasses = [
"tw-peer/toggle-input",
"tw-appearance-none",
"tw-outline-none",
];
protected readonly labelClasses = [
"tw-h-full",
"tw-w-full",
"tw-flex",
"tw-items-center",
"tw-justify-center",
"tw-gap-1.5",
"!tw-font-medium",
"tw-leading-5",
"tw-transition",
"tw-text-center",
"tw-text-sm",
"tw-border-primary-600",
"!tw-text-primary-600",
"tw-border-solid",
"tw-border-y",
"tw-border-r",
"tw-border-l-0",
"tw-cursor-pointer",
"hover:tw-bg-hover-default",
"group-first-of-type/toggle:tw-border-l",
"group-first-of-type/toggle:tw-rounded-s-full",
"group-last-of-type/toggle:tw-rounded-e-full",
"peer-focus-visible/toggle-input:tw-outline-none",
"peer-focus-visible/toggle-input:tw-ring",
"peer-focus-visible/toggle-input:tw-ring-offset-2",
"peer-focus-visible/toggle-input:tw-ring-primary-600",
"peer-focus-visible/toggle-input:tw-z-10",
"peer-focus-visible/toggle-input:tw-bg-primary-600",
"peer-focus-visible/toggle-input:tw-border-primary-600",
"peer-focus-visible/toggle-input:!tw-text-contrast",
"peer-checked/toggle-input:tw-bg-primary-600",
"peer-checked/toggle-input:tw-border-primary-600",
"peer-checked/toggle-input:!tw-text-contrast",
"tw-py-1.5",
"tw-px-3",
// Fix for bootstrap styles that add bottom margin
"!tw-mb-0",
];
}