import { Component, ElementRef, ViewChild } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { A11yModule } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { MdSidenav, MdSidenavContainer } from "./sidenav";
interface SidenavInternals {
_elementRef: ElementRef;
_onTransitionEnd(event: TransitionEvent): void;
}
interface SidenavContainerInternals {
_getMarginLeft(): number;
_getMarginRight(): number;
_getPositionOffset(): number;
_getStyles(): { marginLeft: string; marginRight: string; transform: string };
}
function asInternals(sidenav: MdSidenav): SidenavInternals {
return sidenav as unknown as SidenavInternals;
}
function asContainerInternals(
container: MdSidenavContainer
): SidenavContainerInternals {
return container as unknown as SidenavContainerInternals;
}
@Component({
template: `
Sidenav content
Second sidenav
`,
standalone: false,
})
class HostComponent {
mode: "over" | "push" | "side" = "over";
align: "start" | "end" = "start";
disableClose = false;
backdropClicked = false;
showSecond = false;
@ViewChild(MdSidenav) sidenav: MdSidenav;
@ViewChild(MdSidenavContainer) container: MdSidenavContainer;
onBackdrop() {
this.backdropClicked = true;
}
}
function createFixture(): {
fixture: ComponentFixture;
host: HostComponent;
} {
TestBed.configureTestingModule({
declarations: [HostComponent, MdSidenav, MdSidenavContainer],
imports: [CommonModule, A11yModule],
});
const fixture = TestBed.createComponent(HostComponent);
const host = fixture.componentInstance;
fixture.detectChanges();
return { fixture, host };
}
describe("The Sam Sidenav component", () => {
let fixture: ComponentFixture;
let host: HostComponent;
beforeEach(() => {
({ fixture, host } = createFixture());
});
it("should start closed", () => {
expect(host.sidenav.opened).toBe(false);
expect(host.sidenav._isClosed).toBe(true);
});
it("should resolve open() with an open toggle result once the transition ends", async () => {
const openPromise = host.sidenav.open();
expect(host.sidenav._isOpening).toBe(true);
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
const result = await openPromise;
expect(result.type).toBe("open");
expect(result.animationFinished).toBe(true);
expect(host.sidenav.opened).toBe(true);
});
it("should resolve close() with a close toggle result once the transition ends", async () => {
const openPromise = host.sidenav.open();
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
await openPromise;
const closePromise = host.sidenav.close();
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
const result = await closePromise;
expect(result.type).toBe("close");
expect(host.sidenav.opened).toBe(false);
});
it("should resolve immediately with the current state when toggled to the same state", async () => {
const result = await host.sidenav.toggle(false);
expect(result.type).toBe("close");
});
it("should close on Escape unless disableClose is set", () => {
const closeSpy = vi.spyOn(host.sidenav, "close");
const event = {
keyCode: 27,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
host.sidenav.handleKeydown(event);
expect(closeSpy).toHaveBeenCalled();
closeSpy.mockClear();
host.disableClose = true;
fixture.detectChanges();
host.sidenav.handleKeydown(event);
expect(closeSpy).not.toHaveBeenCalled();
});
it("should emit onAlignChanged when align changes", () => {
let emitCount = 0;
host.sidenav.onAlignChanged.subscribe(() => emitCount++);
host.align = "end";
fixture.detectChanges();
expect(emitCount).toBe(1);
expect(host.sidenav._isEnd).toBe(true);
});
it("should not emit onAlignChanged when align is set to the same value", () => {
let emitCount = 0;
host.sidenav.onAlignChanged.subscribe(() => emitCount++);
host.align = "start";
fixture.detectChanges();
expect(emitCount).toBe(0);
});
it("should expose mode-based class flags", () => {
expect(host.sidenav._modeOver).toBe(true);
host.mode = "side";
fixture.detectChanges();
expect(host.sidenav._modeSide).toBe(true);
host.mode = "push";
fixture.detectChanges();
expect(host.sidenav._modePush).toBe(true);
});
it("should call open and close on both container sidenavs via open()/close()", async () => {
const openSpy = vi.spyOn(host.sidenav, "open");
const containerOpenPromise = host.container.open();
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
await containerOpenPromise;
expect(openSpy).toHaveBeenCalled();
const closeSpy = vi.spyOn(host.sidenav, "close");
const containerClosePromise = host.container.close();
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
await containerClosePromise;
expect(closeSpy).toHaveBeenCalled();
});
it("should emit backdropClick and close non-side sidenavs on backdrop click", async () => {
const openPromise = host.sidenav.open();
asInternals(host.sidenav)._onTransitionEnd({
target: asInternals(host.sidenav)._elementRef.nativeElement,
propertyName: "transform",
} as TransitionEvent);
await openPromise;
fixture.detectChanges();
host.container._onBackdropClicked();
expect(host.backdropClicked).toBe(true);
expect(host.sidenav.opened).toBe(false);
});
it("should throw when two sidenavs share the same align value", () => {
TestBed.resetTestingModule();
@Component({
template: `
One
Two
`,
standalone: false,
})
class DuplicateAlignHostComponent {}
TestBed.configureTestingModule({
declarations: [
DuplicateAlignHostComponent,
MdSidenav,
MdSidenavContainer,
],
imports: [CommonModule, A11yModule],
});
const duplicateFixture = TestBed.createComponent(
DuplicateAlignHostComponent
);
expect(() => duplicateFixture.detectChanges()).toThrow(
/already declared for 'align="start"'/
);
});
it("should compute margin and position styles based on side/push sidenavs", () => {
host.mode = "side";
fixture.detectChanges();
const container = asContainerInternals(host.container);
expect(typeof container._getMarginLeft()).toBe("number");
expect(typeof container._getMarginRight()).toBe("number");
expect(typeof container._getPositionOffset()).toBe("number");
expect(container._getStyles().marginLeft).toContain("px");
});
it("should not throw when a sidenav is added dynamically, enabling transitions once the view settles", async () => {
host.showSecond = true;
expect(() => fixture.detectChanges()).not.toThrow();
// Wait for the fixture (and NgZone) to become stable so the
// first()-gated subscription that enables transitions can run.
await fixture.whenStable();
fixture.detectChanges();
expect(
(host.container as unknown as { _enableTransitions: boolean })
._enableTransitions
).toBe(true);
});
it("should not throw when a sidenav is removed dynamically", async () => {
host.showSecond = true;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
host.showSecond = false;
expect(() => fixture.detectChanges()).not.toThrow();
await fixture.whenStable();
expect(() => fixture.detectChanges()).not.toThrow();
});
it("should not throw and should re-validate drawers when a sidenav's align changes at runtime", async () => {
const validateSpy = vi.spyOn(
host.container as unknown as { _validateDrawers(): void },
"_validateDrawers"
);
validateSpy.mockClear();
host.align = "end";
expect(() => fixture.detectChanges()).not.toThrow();
// The container waits for the microtask queue to be empty (via
// this._ngZone.onMicrotaskEmpty.pipe(first())) before re-validating,
// since both drawers may be swapping sides at the same time.
await fixture.whenStable();
expect(validateSpy).toHaveBeenCalled();
expect(host.sidenav._isEnd).toBe(true);
});
});