Skip to content

Commit 27a8291

Browse files
committed
feat: Add Fullscreen API wrapping the browser Fullscreen API
Adds Component.requestFullscreen() that uses a wrapper approach to handle Vaadin theming and overlay components correctly, along with Page.requestFullscreen() for whole-page fullscreen, Page.exitFullscreen(), and Page.fullscreenSignal() for reactive observation of the fullscreen state (FULLSCREEN, NOT_FULLSCREEN, UNSUPPORTED, UNKNOWN). The signal is seeded from the initial client bootstrap (v-fs parameter) and updated via a vaadin-fullscreen-change DOM event, mirroring the page-visibility wiring. The client-side bridge lives in flow-client/src/main/frontend/Fullscreen.ts, imported from Flow.ts the same way Geolocation.ts is. Fixes #21902
1 parent 32183d1 commit 27a8291

9 files changed

Lines changed: 482 additions & 0 deletions

File tree

flow-client/src/main/frontend/Flow.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ConnectionStateChangeListener,
55
type ConnectionStateStore
66
} from '@vaadin/common-frontend';
7+
import { currentFullscreenState } from './Fullscreen';
78
import './Geolocation';
89
import { currentVisibility } from './PageVisibility';
910

@@ -544,6 +545,8 @@ export class Flow {
544545
params['v-cs'] = colorScheme && colorScheme !== 'normal' ? colorScheme : '';
545546
/* Page visibility — initial state of document.hidden / document.hasFocus() */
546547
params['v-pv'] = currentVisibility();
548+
/* Fullscreen state — initial state of document.fullscreenEnabled / .fullscreenElement */
549+
params['v-fs'] = currentFullscreenState();
547550

548551
/* Theme name - detect which theme is in use */
549552
const computedStyle = getComputedStyle(document.documentElement);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
type VaadinFullscreenState = 'UNSUPPORTED' | 'NOT_FULLSCREEN' | 'FULLSCREEN';
18+
19+
/**
20+
* Returns the current fullscreen state synchronously. Used by the bootstrap
21+
* path to seed the server-side signal without waiting for a DOM event.
22+
*/
23+
export function currentFullscreenState(): VaadinFullscreenState {
24+
if (document.fullscreenEnabled !== true) {
25+
return 'UNSUPPORTED';
26+
}
27+
return document.fullscreenElement ? 'FULLSCREEN' : 'NOT_FULLSCREEN';
28+
}
29+
30+
// Dispatch on document.body so the server-side Page facade (listening on
31+
// the UI element, which is body) can update its signal.
32+
function dispatch(state: VaadinFullscreenState): void {
33+
document.body.dispatchEvent(new CustomEvent('vaadin-fullscreen-change', { detail: state }));
34+
}
35+
36+
// Tracks the most recent component-fullscreen setup so the wrapper can be
37+
// torn down when fullscreen exits (programmatically or via Escape) or when
38+
// a new fullscreen request supersedes it.
39+
let activeComponentReset: (() => void) | undefined;
40+
41+
function resetComponentIfActive(): void {
42+
if (activeComponentReset) {
43+
const fn = activeComponentReset;
44+
activeComponentReset = undefined;
45+
fn();
46+
}
47+
}
48+
49+
document.addEventListener('fullscreenchange', () => {
50+
if (!document.fullscreenElement) {
51+
resetComponentIfActive();
52+
}
53+
dispatch(currentFullscreenState());
54+
});
55+
56+
const $wnd = window as any;
57+
$wnd.Vaadin ??= {};
58+
$wnd.Vaadin.Flow ??= {};
59+
$wnd.Vaadin.Flow.fullscreen = {
60+
/**
61+
* Requests fullscreen for the entire page (document.documentElement).
62+
* No-op if the browser does not support fullscreen.
63+
*/
64+
requestPageFullscreen(): void {
65+
resetComponentIfActive();
66+
if (document.fullscreenEnabled !== true) {
67+
return;
68+
}
69+
document.documentElement.requestFullscreen();
70+
},
71+
72+
/**
73+
* Requests fullscreen for a specific component by moving it into the
74+
* given wrapper element and hiding the rest of the view. Fullscreens
75+
* document.documentElement so that Vaadin theming and overlay
76+
* components keep working. The component is restored to its original
77+
* position on exit (programmatic, Escape, or a superseding request).
78+
* No-op if the browser does not support fullscreen.
79+
*/
80+
requestComponentFullscreen(element: HTMLElement, wrapper: HTMLElement): void {
81+
resetComponentIfActive();
82+
if (document.fullscreenEnabled !== true) {
83+
return;
84+
}
85+
const originalParent = element.parentNode;
86+
if (!originalParent) {
87+
return;
88+
}
89+
const placeholder = document.createComment('vaadin-fullscreen-placeholder');
90+
originalParent.insertBefore(placeholder, element);
91+
92+
wrapper.appendChild(element);
93+
const viewRoot = wrapper.firstChild as HTMLElement | null;
94+
const previousDisplay = viewRoot?.style.display ?? '';
95+
if (viewRoot) {
96+
viewRoot.style.display = 'none';
97+
}
98+
99+
activeComponentReset = () => {
100+
placeholder.parentNode?.insertBefore(element, placeholder);
101+
placeholder.remove();
102+
if (viewRoot) {
103+
viewRoot.style.display = previousDisplay;
104+
}
105+
};
106+
107+
document.documentElement.requestFullscreen();
108+
},
109+
110+
/**
111+
* Exits fullscreen mode if the page is currently in fullscreen.
112+
*/
113+
exitFullscreen(): void {
114+
if (document.fullscreenElement) {
115+
document.exitFullscreen();
116+
}
117+
}
118+
};
119+
120+
// Empty export to ensure TypeScript emits this as an ES module,
121+
// which is required for Vite to load it via import.
122+
export {};

flow-server/src/main/java/com/vaadin/flow/component/Component.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,45 @@ public void scrollIntoView(ScrollOptions scrollOptions) {
903903
getElement().scrollIntoView(scrollOptions);
904904
}
905905

906+
/**
907+
* Requests that the browser display this component in fullscreen mode.
908+
* <p>
909+
* Because of how Vaadin theming and overlay components work, this method
910+
* does not call {@code requestFullscreen()} on the component's element
911+
* directly. Instead, it fullscreens the entire page
912+
* ({@code document.documentElement}), moves the component into a wrapper
913+
* element, and hides the rest of the view. When fullscreen is exited
914+
* (either programmatically via
915+
* {@link com.vaadin.flow.component.page.Page#exitFullscreen()} or by the
916+
* user pressing Escape), the component is automatically restored to its
917+
* original position in the DOM.
918+
* <p>
919+
* Note that browsers require transient user activation (e.g. a button
920+
* click) to enter fullscreen mode. Calling this method from a server push
921+
* or view constructor will not work. The fullscreen state can be observed
922+
* via {@link com.vaadin.flow.component.page.Page#fullscreenSignal()}; calls
923+
* made while the state is
924+
* {@link com.vaadin.flow.component.page.FullscreenState#UNSUPPORTED
925+
* UNSUPPORTED} are no-ops on the client.
926+
*
927+
* @throws IllegalStateException
928+
* if the component is not attached to a UI
929+
* @see com.vaadin.flow.component.page.Page#requestFullscreen()
930+
* @see com.vaadin.flow.component.page.Page#exitFullscreen()
931+
* @see com.vaadin.flow.component.page.Page#fullscreenSignal()
932+
* @see <a href=
933+
* "https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN
934+
* Fullscreen API</a>
935+
*/
936+
public void requestFullscreen() {
937+
UI ui = getUI().orElseThrow(() -> new IllegalStateException(
938+
"Component must be attached to the UI to request fullscreen"));
939+
Element wrapperElement = ui.getInternals().getWrapperElement();
940+
ui.getElement().executeJs(
941+
"window.Vaadin.Flow.fullscreen.requestComponentFullscreen($0, $1)",
942+
getElement(), wrapperElement);
943+
}
944+
906945
/**
907946
* Traverses the component tree up and returns the first ancestor component
908947
* that matches the given type.

flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) {
499499
getStringElseNull.apply("v-tn"));
500500
ui.getInternals().setExtendedClientDetails(details);
501501
ui.getPage().setPageVisibility(getStringElseNull.apply("v-pv"));
502+
ui.getPage().setFullscreenState(getStringElseNull.apply("v-fs"));
502503
String ga = getStringElseNull.apply("v-ga");
503504
if (ga != null) {
504505
try {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.page;
17+
18+
import com.vaadin.flow.component.Component;
19+
20+
/**
21+
* Represents the fullscreen state of a browser page.
22+
* <p>
23+
* Wraps the browser's Fullscreen API ({@code document.fullscreenEnabled} and
24+
* {@code document.fullscreenElement}) into four observable states: the browser
25+
* does not support fullscreen at all, fullscreen is supported but the page is
26+
* not in it, the page is currently fullscreen, and an {@link #UNKNOWN} sentinel
27+
* used before the first value has arrived from the client.
28+
*
29+
* @see Page#fullscreenSignal()
30+
* @see Page#requestFullscreen()
31+
* @see Page#exitFullscreen()
32+
* @see Component#requestFullscreen()
33+
*/
34+
public enum FullscreenState {
35+
36+
/**
37+
* No value has been reported by the browser yet. Used only as the initial
38+
* value of the signal before the first client handshake delivers the real
39+
* one. In normal request handling the signal is seeded before any user code
40+
* (UI initialization, {@code UIInitListener}, component attach) runs, so
41+
* this value is essentially never observed in practice; once a real value
42+
* has arrived, the signal never returns to {@code UNKNOWN}.
43+
*/
44+
UNKNOWN,
45+
46+
/**
47+
* The browser does not support fullscreen mode, or the document is not
48+
* permitted to enter it. In the browser, this corresponds to
49+
* {@code document.fullscreenEnabled} being {@code false}. Calls to
50+
* {@link Page#requestFullscreen()} or {@link Component#requestFullscreen()}
51+
* are no-ops in this state.
52+
*/
53+
UNSUPPORTED,
54+
55+
/**
56+
* Fullscreen mode is supported and the page is currently not in it. In the
57+
* browser, this corresponds to {@code document.fullscreenEnabled} being
58+
* {@code true} and {@code document.fullscreenElement} being {@code null}.
59+
*/
60+
NOT_FULLSCREEN,
61+
62+
/**
63+
* The page is currently in fullscreen mode. In the browser, this
64+
* corresponds to {@code document.fullscreenElement} being non-{@code null}.
65+
*/
66+
FULLSCREEN
67+
}

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.slf4j.Logger;
2828
import org.slf4j.LoggerFactory;
2929

30+
import com.vaadin.flow.component.Component;
3031
import com.vaadin.flow.component.Direction;
3132
import com.vaadin.flow.component.UI;
3233
import com.vaadin.flow.component.dependency.JavaScript;
@@ -66,6 +67,10 @@ public class Page implements Serializable {
6667
PageVisibility.UNKNOWN);
6768
private final Signal<PageVisibility> pageVisibilityReadOnly = pageVisibilitySignal
6869
.asReadonly();
70+
private final ValueSignal<FullscreenState> fullscreenSignal = new ValueSignal<>(
71+
FullscreenState.UNKNOWN);
72+
private final Signal<FullscreenState> fullscreenSignalReadOnly = fullscreenSignal
73+
.asReadonly();
6974

7075
/**
7176
* Creates a page instance for the given UI.
@@ -80,6 +85,10 @@ public Page(UI ui) {
8085
.addEventListener("vaadin-page-visibility-change",
8186
e -> setPageVisibility(e.getEventDetail(String.class)))
8287
.addEventDetail().debounce(100).allowInert();
88+
ui.getElement()
89+
.addEventListener("vaadin-fullscreen-change",
90+
e -> setFullscreenState(e.getEventDetail(String.class)))
91+
.addEventDetail().allowInert();
8392
}
8493

8594
/**
@@ -551,6 +560,100 @@ void setPageVisibility(String value) {
551560
}
552561
}
553562

563+
/**
564+
* Returns a read-only signal that tracks the browser's fullscreen state.
565+
* <p>
566+
* The signal distinguishes between {@link FullscreenState#FULLSCREEN
567+
* FULLSCREEN} (the page is currently in fullscreen),
568+
* {@link FullscreenState#NOT_FULLSCREEN NOT_FULLSCREEN} (fullscreen is
569+
* supported but the page is not in it), {@link FullscreenState#UNSUPPORTED
570+
* UNSUPPORTED} (the browser does not support fullscreen or the document is
571+
* not permitted to enter it), and {@link FullscreenState#UNKNOWN UNKNOWN}
572+
* (the initial value, replaced with a real one before any user code
573+
* observes the signal).
574+
* <p>
575+
* The signal value is seeded from the initial client bootstrap, so user
576+
* code always sees a real value. Subscribe with
577+
* {@code Signal.effect(owner, ...)} to react to changes; call
578+
* {@code fullscreenSignal().peek()} for a snapshot outside a reactive
579+
* context, and {@code .get()} inside one. Use {@link #requestFullscreen()},
580+
* {@link Component#requestFullscreen()}, or {@link #exitFullscreen()} to
581+
* change the state.
582+
* <p>
583+
* Note that browsers require transient user activation (e.g. a button
584+
* click) to enter fullscreen mode, so the signal will not transition to
585+
* {@link FullscreenState#FULLSCREEN FULLSCREEN} in response to a request
586+
* from a server push or view constructor.
587+
*
588+
* @return the read-only fullscreen signal
589+
*/
590+
public Signal<FullscreenState> fullscreenSignal() {
591+
return fullscreenSignalReadOnly;
592+
}
593+
594+
/**
595+
* Requests that the browser display the entire page in fullscreen mode.
596+
* <p>
597+
* This calls {@code document.documentElement.requestFullscreen()} on the
598+
* browser. Themes and overlay components (such as Notification and ComboBox
599+
* popups) work correctly in this mode. Use
600+
* {@link Component#requestFullscreen()} to fullscreen a single component
601+
* within the page.
602+
* <p>
603+
* Note that browsers require transient user activation (e.g. a button
604+
* click) to enter fullscreen mode. Calling this method from a server push
605+
* or view constructor will not work. The fullscreen state can be observed
606+
* via {@link #fullscreenSignal()}; calls made while the state is
607+
* {@link FullscreenState#UNSUPPORTED UNSUPPORTED} are no-ops on the client.
608+
*
609+
* @see Component#requestFullscreen()
610+
* @see #exitFullscreen()
611+
* @see #fullscreenSignal()
612+
* @see <a href=
613+
* "https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN
614+
* Fullscreen API</a>
615+
*/
616+
public void requestFullscreen() {
617+
executeJs("window.Vaadin.Flow.fullscreen.requestPageFullscreen()");
618+
}
619+
620+
/**
621+
* Exits fullscreen mode if the page is currently in fullscreen, otherwise a
622+
* no-op.
623+
* <p>
624+
* If a component was previously fullscreened via
625+
* {@link Component#requestFullscreen()}, it is automatically restored to
626+
* its original position in the DOM.
627+
*
628+
* @see #requestFullscreen()
629+
* @see Component#requestFullscreen()
630+
*/
631+
public void exitFullscreen() {
632+
executeJs("window.Vaadin.Flow.fullscreen.exitFullscreen()");
633+
}
634+
635+
/**
636+
* Sets the fullscreen state from a raw client-side value (e.g. from the
637+
* bootstrap parameters or from a {@code vaadin-fullscreen-change} DOM
638+
* event). {@code null} and unknown values are ignored — the latter is
639+
* logged at debug level so a forward-compatible client value does not
640+
* silently disappear.
641+
*
642+
* @param value
643+
* the raw value, or {@code null}
644+
*/
645+
void setFullscreenState(String value) {
646+
if (value == null) {
647+
return;
648+
}
649+
try {
650+
fullscreenSignal.set(FullscreenState.valueOf(value));
651+
} catch (IllegalArgumentException e) {
652+
LOGGER.debug("Unknown fullscreen state value from client: {}",
653+
value);
654+
}
655+
}
656+
554657
/**
555658
* Opens the given url in a new tab.
556659
*

0 commit comments

Comments
 (0)