diff --git a/projects/core/browser/fullscreen/index.test.ts b/projects/core/browser/fullscreen/index.test.ts
index cf6e2f6..f6d17d4 100644
--- a/projects/core/browser/fullscreen/index.test.ts
+++ b/projects/core/browser/fullscreen/index.test.ts
@@ -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';
@@ -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: '
{{ fs.isActive() }}',
+ encapsulation: ViewEncapsulation.ShadowDom,
+ })
+ class ShadowComponent {
+ readonly box = viewChild.required>('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: '{{ fs.isActive() }}',
+ encapsulation: ViewEncapsulation.ShadowDom,
+ })
+ class HostTargetComponent {
+ readonly box = viewChild.required>('box');
+ readonly host = inject(ElementRef);
+ 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: '{{ fs.isActive() }}',
+ encapsulation: ViewEncapsulation.ShadowDom,
+ })
+ class HostTargetComponent {
+ readonly host = inject(ElementRef);
+ 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);
+ });
+ });
});
diff --git a/projects/core/browser/fullscreen/index.ts b/projects/core/browser/fullscreen/index.ts
index dc9070d..f48a6f5 100644
--- a/projects/core/browser/fullscreen/index.ts
+++ b/projects/core/browser/fullscreen/index.ts
@@ -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';
@@ -105,7 +111,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {
const target = options?.target ?? document.documentElement;
- const fullscreenElement = signal(document.fullscreenElement);
+ const fullscreenElement = signal(getFullscreenElement(document));
const isActive = computed(() => {
const current = fullscreenElement();
@@ -115,7 +121,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {
const enter = async (): Promise => {
const el = toElement.untracked(target);
ngDevMode && assertElement(el, 'fullscreen');
- if (document.fullscreenElement !== el) {
+ if (getFullscreenElement(document) !== el) {
await el?.requestFullscreen();
}
};
@@ -123,7 +129,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {
const exit = async (): Promise => {
const el = toElement.untracked(target);
ngDevMode && assertElement(el, 'fullscreen');
- if (document.fullscreenElement === el) {
+ if (getFullscreenElement(document) === el) {
await document.exitFullscreen();
}
};
@@ -138,7 +144,7 @@ export function fullscreen(options?: FullscreenOptions): FullscreenRef {
setupSync(() => {
listener(document, 'fullscreenchange', () =>
- fullscreenElement.set(document.fullscreenElement)
+ fullscreenElement.set(getFullscreenElement(document))
);
});
diff --git a/projects/core/internal/utils/dom/get-fullscreen-element.ts b/projects/core/internal/utils/dom/get-fullscreen-element.ts
new file mode 100644
index 0000000..b02196b
--- /dev/null
+++ b/projects/core/internal/utils/dom/get-fullscreen-element.ts
@@ -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;
+}
diff --git a/projects/core/internal/utils/dom/index.ts b/projects/core/internal/utils/dom/index.ts
index 5b26575..033ef21 100644
--- a/projects/core/internal/utils/dom/index.ts
+++ b/projects/core/internal/utils/dom/index.ts
@@ -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';