Skip to content
Merged
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
118 changes: 117 additions & 1 deletion projects/core/browser/fullscreen/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, ElementRef, signal, viewChild } from '@angular/core';
import { Component, ElementRef, inject, signal, ViewEncapsulation, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { fullscreen } from './index';

Expand Down Expand Up @@ -188,4 +188,120 @@ describe(fullscreen.name, () => {
expect(component.fs.isActive()).toBe(true);
});
});

describe('shadow dom', () => {
// `document.fullscreenElement` is retargeted against the document tree: when the fullscreen
// element lives in a shadow tree, the document reports its host instead.
// See https://fullscreen.spec.whatwg.org/#dom-documentorshadowroot-fullscreenelement
const mockShadowFullscreenElement = (root: ShadowRoot, element: Element | null) => {
Object.defineProperty(root, 'fullscreenElement', {
get: () => element,
configurable: true,
});
};

@Component({
template: '<div #box></div>{{ fs.isActive() }}',
encapsulation: ViewEncapsulation.ShadowDom,
})
class ShadowComponent {
readonly box = viewChild.required<ElementRef<HTMLElement>>('box');
readonly fs = fullscreen({ target: this.box });
}

const createShadowComponent = () => {
const fixture = TestBed.createComponent(ShadowComponent);
fixture.detectChanges();

const component = fixture.componentInstance;

return {
component,
host: fixture.nativeElement as Element,
box: component.box().nativeElement,
};
};

it('should report a target inside a shadow root as active', () => {
const { component, host, box } = createShadowComponent();

fullscreenElementValue = host;
mockShadowFullscreenElement(host.shadowRoot!, box);
document.dispatchEvent(new Event('fullscreenchange'));

expect(component.fs.isActive()).toBe(true);
});

it('should exit a target inside a shadow root', async () => {
const { component, host, box } = createShadowComponent();

fullscreenElementValue = host;
mockShadowFullscreenElement(host.shadowRoot!, box);
document.dispatchEvent(new Event('fullscreenchange'));

await component.fs.exit();

expect(exitFullscreenSpy).toHaveBeenCalled();
});

it('should not enter again when the target inside a shadow root is already fullscreen', async () => {
const { component, host, box } = createShadowComponent();

fullscreenElementValue = host;
mockShadowFullscreenElement(host.shadowRoot!, box);
document.dispatchEvent(new Event('fullscreenchange'));

await component.fs.enter();

expect(requestFullscreenSpy).not.toHaveBeenCalled();
});

it('should not report the host as active when a nested element is fullscreen', () => {
@Component({
template: '<div #box></div>{{ fs.isActive() }}',
encapsulation: ViewEncapsulation.ShadowDom,
})
class HostTargetComponent {
readonly box = viewChild.required<ElementRef<HTMLElement>>('box');
readonly host = inject(ElementRef<HTMLElement>);
readonly fs = fullscreen({ target: this.host });
}

const fixture = TestBed.createComponent(HostTargetComponent);
fixture.detectChanges();

const component = fixture.componentInstance;
const host = fixture.nativeElement as Element;

fullscreenElementValue = host;
mockShadowFullscreenElement(host.shadowRoot!, component.box().nativeElement);
document.dispatchEvent(new Event('fullscreenchange'));

expect(component.fs.isActive()).toBe(false);
});

it('should report a shadow host that is fullscreen itself as active', () => {
@Component({
template: '<div #box></div>{{ fs.isActive() }}',
encapsulation: ViewEncapsulation.ShadowDom,
})
class HostTargetComponent {
readonly host = inject(ElementRef<HTMLElement>);
readonly fs = fullscreen({ target: this.host });
}

const fixture = TestBed.createComponent(HostTargetComponent);
fixture.detectChanges();

const component = fixture.componentInstance;
const host = fixture.nativeElement as Element;

// The host went fullscreen itself, so its shadow root reports no fullscreen element.
fullscreenElementValue = host;
mockShadowFullscreenElement(host.shadowRoot!, null);
document.dispatchEvent(new Event('fullscreenchange'));

expect(component.fs.isActive()).toBe(true);
});
});
});
16 changes: 11 additions & 5 deletions projects/core/browser/fullscreen/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { computed, signal, type Signal, untracked } from '@angular/core';
import { assertElement, constSignal, NOOP_ASYNC_FN, setupContext } from '@signality/core/internal';
import {
assertElement,
constSignal,
getFullscreenElement,
NOOP_ASYNC_FN,
setupContext,
} from '@signality/core/internal';
import { toElement } from '@signality/core/utilities';
import type { MaybeElementSignal, WithInjector } from '@signality/core/types';
import { listener, setupSync } from '@signality/core/browser/listener';
Expand Down Expand Up @@ -105,7 +111,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {

const target = options?.target ?? document.documentElement;

const fullscreenElement = signal<Element | null>(document.fullscreenElement);
const fullscreenElement = signal<Element | null>(getFullscreenElement(document));

const isActive = computed(() => {
const current = fullscreenElement();
Expand All @@ -115,15 +121,15 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {
const enter = async (): Promise<void> => {
const el = toElement.untracked(target);
ngDevMode && assertElement(el, 'fullscreen');
if (document.fullscreenElement !== el) {
if (getFullscreenElement(document) !== el) {
await el?.requestFullscreen();
}
};

const exit = async (): Promise<void> => {
const el = toElement.untracked(target);
ngDevMode && assertElement(el, 'fullscreen');
if (document.fullscreenElement === el) {
if (getFullscreenElement(document) === el) {
await document.exitFullscreen();
}
};
Expand All @@ -138,7 +144,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {

setupSync(() => {
listener(document, 'fullscreenchange', () =>
fullscreenElement.set(document.fullscreenElement)
fullscreenElement.set(getFullscreenElement(document))
);
});

Expand Down
22 changes: 22 additions & 0 deletions projects/core/internal/utils/dom/get-fullscreen-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @internal
*/
export function getFullscreenElement(document: Document): Element | null {
let fullscreenElement = document.fullscreenElement;

// `fullscreenElement` is retargeted against the tree it is read from, so a document reports
// the shadow host instead of the element that actually went fullscreen.
// See https://fullscreen.spec.whatwg.org/#dom-documentorshadowroot-fullscreenelement
while (fullscreenElement && fullscreenElement.shadowRoot) {
const newFullscreenElement = fullscreenElement.shadowRoot.fullscreenElement;
// A host with a shadow root can be the fullscreen element itself, in which case its shadow
// root reports `null` and the host is the element we are looking for.
if (!newFullscreenElement || newFullscreenElement === fullscreenElement) {
break;
} else {
fullscreenElement = newFullscreenElement;
}
}

return fullscreenElement;
}
1 change: 1 addition & 0 deletions projects/core/internal/utils/dom/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './get-active-element';
export * from './get-event-target';
export * from './get-fullscreen-element';
export * from './get-pip-element';
export * from './get-shadow-root';
export * from './is-document';
Expand Down
Loading