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';