Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions adev/shared-docs/styles/_z-index.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
:root {
--z-index-easter-egg: 250;
--z-index-mini-menu: 200;
--z-index-top-level-banner: 150;
--z-index-nav: 100;
Expand Down
10 changes: 10 additions & 0 deletions adev/src/app/core/layout/angry-angie/angry-angie.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@if (angie.show()) {
<div
class="adev-angry-angie"
animate.enter="adev-angry-angie--entering"
animate.leave="adev-angry-angie--leaving"
aria-hidden="true"
>
<img src="assets/images/angie/angry.svg" alt="" />
</div>
}
66 changes: 66 additions & 0 deletions adev/src/app/core/layout/angry-angie/angry-angie.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.adev-angry-angie {
position: fixed;
inset: 0;
z-index: var(--z-index-easter-egg);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;

img {
display: block;
width: min(340px, 60vw);
height: auto;
animation: adev-angry-angie-shake 0.8s ease-in-out 0.35s 3;
}
}

.adev-angry-angie--entering {
animation: adev-angry-angie-in 0.35s ease-out;
}

.adev-angry-angie--leaving {
animation: adev-angry-angie-out 0.3s ease-in forwards;
}

@keyframes adev-angry-angie-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}

@keyframes adev-angry-angie-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.5);
}
}

@keyframes adev-angry-angie-shake {
from,
to {
transform: translate3d(0, 0, 0);
}
10%,
30%,
50%,
70%,
90% {
transform: translate3d(-8px, 0, 0);
}
20%,
40%,
60%,
80% {
transform: translate3d(8px, 0, 0);
}
}
43 changes: 43 additions & 0 deletions adev/src/app/core/layout/angry-angie/angry-angie.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';

import {AngryAngie} from '../../services/angry-angie.service';
import {AngryAngieComponent} from './angry-angie.component';

describe('AngryAngieComponent', () => {
const fakeAngie = {show: signal(false)};
let fixture: ComponentFixture<AngryAngieComponent>;

beforeEach(async () => {
fakeAngie.show.set(false);
TestBed.configureTestingModule({
imports: [AngryAngieComponent],
providers: [{provide: AngryAngie, useValue: fakeAngie}],
});
fixture = TestBed.createComponent(AngryAngieComponent);
await fixture.whenStable();
});

const overlay = () => fixture.debugElement.query(By.css('.adev-angry-angie'));

it('renders nothing while the easter egg is hidden', () => {
expect(overlay()).toBeNull();
});

it('renders the overlay while the easter egg is showing', async () => {
fakeAngie.show.set(true);
await fixture.whenStable();

expect(overlay()).not.toBeNull();
expect(overlay().query(By.css('img'))).not.toBeNull();
});
});
20 changes: 20 additions & 0 deletions adev/src/app/core/layout/angry-angie/angry-angie.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {Component, inject} from '@angular/core';

import {AngryAngie} from '../../services/angry-angie.service';

@Component({
selector: 'adev-angry-angie',
templateUrl: './angry-angie.component.html',
styleUrl: './angry-angie.component.scss',
})
export class AngryAngieComponent {
protected readonly angie = inject(AngryAngie);
}
2 changes: 2 additions & 0 deletions adev/src/app/core/layout/navigation/navigation.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,12 @@
type="button"
[cdkMenuTriggerFor]="themeMiniMenu"
[cdkMenuPosition]="bottomMiniMenuPositions"
[disabled]="showAngryAngie()"
[aria-label]="'Change theme. Current theme: ' + theme()"
aria-controls="theme-mini-menu"
[aria-expanded]="openedMenu() === 'theme-picker'"
aria-haspopup="menu"
(click)="registerThemeTap()"
(cdkMenuClosed)="closeMenu()"
(cdkMenuOpened)="openMenu('theme-picker')"
>
Expand Down
19 changes: 18 additions & 1 deletion adev/src/app/core/layout/navigation/navigation.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {CdkMenu, CdkMenuItem, CdkMenuTrigger} from '@angular/cdk/menu';
import {ConnectedPosition, ConnectionPositionPair} from '@angular/cdk/overlay';
import {DOCUMENT, Location, isPlatformBrowser} from '@angular/common';
import {Component, PLATFORM_ID, inject, signal} from '@angular/core';
import {Component, PLATFORM_ID, computed, inject, injectAsync, onIdle, signal} from '@angular/core';
import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop';
import {
ClickOutside,
Expand All @@ -28,6 +28,7 @@ import {ANGULAR_LINKS} from '../../constants/links';
import {PAGE_PREFIX} from '../../constants/pages';
import {Theme, ThemeManager} from '../../services/theme-manager.service';
import {VersionManager} from '../../services/version-manager.service';
import type {AngryAngie} from '../../services/angry-angie.service';

type MenuType = 'social' | 'theme-picker' | 'version-picker';

Expand Down Expand Up @@ -80,6 +81,13 @@ export class Navigation {
protected readonly theme = this.themeManager.theme;
protected readonly openedMenu = signal<MenuType | null>(null);

private readonly angryAngieLoader = injectAsync(
() => import('../../services/angry-angie.service').then((m) => m.AngryAngie),
{prefetch: onIdle},
);
private readonly angryAngie = signal<AngryAngie | null>(null);
protected readonly showAngryAngie = computed(() => this.angryAngie()?.show() ?? false);

protected readonly currentDocsVersion = this.versionManager.currentDocsVersion;
protected readonly currentDocsVersionMode = this.versionManager.currentDocsVersionMode;

Expand All @@ -104,12 +112,21 @@ export class Navigation {
this.listenToRouteChange();
this.preventToScrollContentWhenSecondaryNavIsOpened();
this.closeMobileNavOnPrimaryRouteChange();

this.angryAngieLoader().then(
(angryAngie) => this.angryAngie.set(angryAngie),
() => {},
);
}

protected setTheme(theme: Theme): void {
this.themeManager.setTheme(theme);
}

protected registerThemeTap(): void {
Comment thread
erkamyaman marked this conversation as resolved.
this.angryAngie()?.registerTap();
}

protected openVersionMenu($event: MouseEvent): void {
// It's required to avoid redirection to `home`
$event.stopImmediatePropagation();
Expand Down
124 changes: 124 additions & 0 deletions adev/src/app/core/services/angry-angie.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*!
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {DOCUMENT} from '@angular/common';
import {TestBed} from '@angular/core/testing';
import {NavigationState} from '@angular/docs';

import {AngryAngie} from './angry-angie.service';

describe('AngryAngie', () => {
// Timings mirror the service's internal constants.
const TAP_WINDOW_MS = 2000;
const VISIBLE_MS = 6000;
const STROBE_MS = 400;

let service: AngryAngie;
let root: DOMTokenList;

beforeEach(() => {
TestBed.configureTestingModule({providers: [AngryAngie]});
service = TestBed.inject(AngryAngie);
root = TestBed.inject(DOCUMENT).documentElement.classList;
root.remove('docs-dark-mode', 'docs-light-mode');
jasmine.clock().install();
});

afterEach(() => {
jasmine.clock().uninstall();
root.remove('docs-dark-mode', 'docs-light-mode');
});

const tap = (times: number) => {
for (let i = 0; i < times; i++) {
service.registerTap();
}
};
const mockReducedMotion = (reduce: boolean) => {
const realMatchMedia = window.matchMedia.bind(window);
spyOn(window, 'matchMedia').and.callFake((query: string) =>
query.includes('prefers-reduced-motion')
? ({matches: reduce} as MediaQueryList)
: realMatchMedia(query),
);
};

it('should not trigger before the tap threshold', () => {
tap(9);
expect(service.show()).toBe(false);
});

it('should trigger once the tap threshold is reached', () => {
tap(10);
expect(service.show()).toBe(true);
});

it('should reset the count when taps are spaced beyond the window', () => {
tap(9);
jasmine.clock().tick(TAP_WINDOW_MS + 1); // window elapses, count resets to 0

tap(9);
expect(service.show()).toBe(false); // 9 taps in the new window is still below threshold

tap(1);
expect(service.show()).toBe(true); // the 10th within the window fires it
});

it('should hide after the visible duration', () => {
mockReducedMotion(false);
tap(10);
expect(service.show()).toBe(true);

jasmine.clock().tick(VISIBLE_MS);
expect(service.show()).toBe(false);
});

it('should ignore further taps while it is on screen', () => {
mockReducedMotion(false);
tap(10);
expect(service.show()).toBe(true);

tap(10); // ignored while showing, so it stays shown rather than re-arming
expect(service.show()).toBe(true);
});

it('should close the mobile nav when it fires', () => {
mockReducedMotion(false);
const navigationState = TestBed.inject(NavigationState);
navigationState.setMobileNavigationListVisibility(true);

tap(10);

expect(navigationState.isMobileNavVisible()).toBe(false);
});

it('should strobe the theme while on screen and restore it afterward', () => {
mockReducedMotion(false);
root.add('docs-light-mode'); // starting theme

tap(10);
jasmine.clock().tick(STROBE_MS);
expect(root.contains('docs-dark-mode')).toBe(true); // flipped while on screen

jasmine.clock().tick(VISIBLE_MS);
expect(root.contains('docs-light-mode')).toBe(true); // restored to the starting theme
expect(root.contains('docs-dark-mode')).toBe(false);
});

it('should not strobe the theme when the user prefers reduced motion', () => {
mockReducedMotion(true);
root.add('docs-dark-mode'); // starting theme

tap(10);
expect(service.show()).toBe(true); // she still shows

jasmine.clock().tick(VISIBLE_MS);
expect(root.contains('docs-dark-mode')).toBe(true); // theme untouched, no strobe
expect(root.contains('docs-light-mode')).toBe(false);
});
});
Loading
Loading