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 { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
|
|
||||||
@@ -30,31 +30,29 @@ describe("Button", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should select second element when setting selected to second", () => {
|
it("should select second element when setting selected to second", () => {
|
||||||
testAppComponent.selected = "second";
|
testAppComponent.selected.set("second");
|
||||||
fixture.detectChanges();
|
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", () => {
|
it("should not select second element when setting selected to third", () => {
|
||||||
testAppComponent.selected = "third";
|
testAppComponent.selected.set("third");
|
||||||
fixture.detectChanges();
|
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", () => {
|
it("should emit new value when changing selection by clicking on radio button", () => {
|
||||||
testAppComponent.selected = "first";
|
testAppComponent.selected.set("first");
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
radioButtons[1].click();
|
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({
|
@Component({
|
||||||
selector: "test-app",
|
selector: "test-app",
|
||||||
template: `
|
template: `
|
||||||
@@ -65,7 +63,8 @@ describe("Button", () => {
|
|||||||
</bit-toggle-group>
|
</bit-toggle-group>
|
||||||
`,
|
`,
|
||||||
imports: [ToggleGroupModule],
|
imports: [ToggleGroupModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
class TestAppComponent {
|
class TestAppComponent {
|
||||||
selected?: string;
|
readonly selected: WritableSignal<string | undefined> = signal(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
import {
|
import {
|
||||||
booleanAttribute,
|
booleanAttribute,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
computed,
|
||||||
HostBinding,
|
|
||||||
Output,
|
|
||||||
input,
|
input,
|
||||||
model,
|
model,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
|
|
||||||
let nextId = 0;
|
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({
|
@Component({
|
||||||
selector: "bit-toggle-group",
|
selector: "bit-toggle-group",
|
||||||
templateUrl: "./toggle-group.component.html",
|
templateUrl: "./toggle-group.component.html",
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
role: "radiogroup",
|
||||||
|
"[class]": "classlist()",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class ToggleGroupComponent<TValue = unknown> {
|
export class ToggleGroupComponent<TValue = unknown> {
|
||||||
private id = nextId++;
|
private readonly id = nextId++;
|
||||||
name = `bit-toggle-group-${this.id}`;
|
|
||||||
|
|
||||||
|
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 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")
|
* The selected value in the toggle group.
|
||||||
get classList() {
|
*/
|
||||||
return ["tw-flex"].concat(this.fullWidth() ? ["tw-w-full", "[&>*]:tw-flex-1"] : []);
|
readonly selected = model<TValue>();
|
||||||
}
|
|
||||||
|
protected readonly classlist = computed(() =>
|
||||||
|
["tw-flex"].concat(this.fullWidth() ? ["tw-w-full", "[&>*]:tw-flex-1"] : []),
|
||||||
|
);
|
||||||
|
|
||||||
onInputInteraction(value: TValue) {
|
onInputInteraction(value: TValue) {
|
||||||
this.selected.set(value);
|
this.selected.set(value);
|
||||||
this.selectedChange.emit(value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
id="bit-toggle-{{ id }}"
|
[id]="id"
|
||||||
[name]="name"
|
[name]="name"
|
||||||
[ngClass]="inputClasses"
|
[class]="inputClasses"
|
||||||
[checked]="selected"
|
[checked]="selected()"
|
||||||
(change)="onInputInteraction()"
|
(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>
|
<span class="tw-line-clamp-2 tw-break-words" #labelContent>
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</span>
|
</span>
|
||||||
<span class="tw-shrink-0" #bitBadgeContainer [hidden]="!bitBadgeContainerHasChidlren()">
|
<span class="tw-shrink-0" [hidden]="!hasBadge()">
|
||||||
<ng-content select="[bitBadge]"></ng-content>
|
<ng-content select="[bitBadge]"></ng-content>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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 { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
|
|
||||||
|
import { BadgeModule } from "../badge";
|
||||||
|
|
||||||
import { ToggleGroupComponent } from "./toggle-group.component";
|
import { ToggleGroupComponent } from "./toggle-group.component";
|
||||||
import { ToggleGroupModule } from "./toggle-group.module";
|
import { ToggleGroupModule } from "./toggle-group.module";
|
||||||
|
|
||||||
@@ -45,8 +47,45 @@ describe("Toggle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
describe("Toggle with badge content", () => {
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
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({
|
@Component({
|
||||||
selector: "test-component",
|
selector: "test-component",
|
||||||
template: `
|
template: `
|
||||||
@@ -56,7 +95,24 @@ describe("Toggle", () => {
|
|||||||
</bit-toggle-group>
|
</bit-toggle-group>
|
||||||
`,
|
`,
|
||||||
imports: [ToggleGroupModule],
|
imports: [ToggleGroupModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
class TestComponent {
|
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,113 +1,109 @@
|
|||||||
import { NgClass } from "@angular/common";
|
|
||||||
import {
|
import {
|
||||||
AfterContentChecked,
|
afterNextRender,
|
||||||
AfterViewInit,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
|
contentChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
HostBinding,
|
inject,
|
||||||
signal,
|
|
||||||
input,
|
input,
|
||||||
|
signal,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
|
|
||||||
|
import { BadgeComponent } from "../badge";
|
||||||
|
|
||||||
import { ToggleGroupComponent } from "./toggle-group.component";
|
import { ToggleGroupComponent } from "./toggle-group.component";
|
||||||
|
|
||||||
let nextId = 0;
|
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({
|
@Component({
|
||||||
selector: "bit-toggle",
|
selector: "bit-toggle",
|
||||||
templateUrl: "./toggle.component.html",
|
templateUrl: "./toggle.component.html",
|
||||||
imports: [NgClass],
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
tabindex: "-1",
|
||||||
|
"[class]": "hostClasses",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class ToggleComponent<TValue> implements AfterContentChecked, AfterViewInit {
|
export class ToggleComponent<TValue> {
|
||||||
id = nextId++;
|
protected readonly id = "bit-toggle-" + nextId++;
|
||||||
|
|
||||||
|
private readonly groupComponent = inject(ToggleGroupComponent<TValue>);
|
||||||
|
|
||||||
readonly value = input.required<TValue>();
|
readonly value = input.required<TValue>();
|
||||||
readonly labelContent = viewChild<ElementRef<HTMLSpanElement>>("labelContent");
|
protected readonly labelContent = viewChild<ElementRef<HTMLSpanElement>>("labelContent");
|
||||||
readonly bitBadgeContainer = viewChild<ElementRef<HTMLSpanElement>>("bitBadgeContainer");
|
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);
|
protected readonly labelTitle = signal<string | null>(null);
|
||||||
|
|
||||||
get name() {
|
constructor() {
|
||||||
return this.groupComponent.name;
|
// Set label title after view is initialized
|
||||||
|
afterNextRender(() => {
|
||||||
|
const labelText = this.labelContent()?.nativeElement.innerText;
|
||||||
|
if (labelText) {
|
||||||
|
this.labelTitle.set(labelText);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get selected() {
|
protected readonly name = this.groupComponent.name;
|
||||||
return this.groupComponent.selected() === this.value();
|
readonly selected = computed(() => this.groupComponent.selected() === this.value());
|
||||||
}
|
|
||||||
|
|
||||||
get inputClasses() {
|
protected handleInputChange() {
|
||||||
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() {
|
|
||||||
this.groupComponent.onInputInteraction(this.value());
|
this.groupComponent.onInputInteraction(this.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterContentChecked() {
|
protected readonly hostClasses = ["tw-group/toggle", "tw-flex", "tw-min-w-16"];
|
||||||
this.bitBadgeContainerHasChidlren.set(
|
|
||||||
(this.bitBadgeContainer()?.nativeElement.childElementCount ?? 0) > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit() {
|
protected readonly inputClasses = [
|
||||||
const labelText = this.labelContent()?.nativeElement.innerText;
|
"tw-peer/toggle-input",
|
||||||
if (labelText) {
|
"tw-appearance-none",
|
||||||
this.labelTitle.set(labelText);
|
"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",
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user