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
6 changes: 6 additions & 0 deletions flow-client/src/main/frontend/Flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@vaadin/common-frontend';
import './Geolocation';
import { currentVisibility } from './PageVisibility';
import { currentScreenOrientationAngle, currentScreenOrientationType } from './ScreenOrientation';

export interface FlowConfig {
imports?: () => Promise<any>;
Expand Down Expand Up @@ -545,6 +546,11 @@ export class Flow {
/* Page visibility — initial state of document.hidden / document.hasFocus() */
params['v-pv'] = currentVisibility();

/* Screen orientation — initial state of screen.orientation, empty
when the Screen Orientation API is unavailable. */
params['v-so'] = currentScreenOrientationType();
params['v-soa'] = currentScreenOrientationAngle();

/* Theme name - detect which theme is in use */
const computedStyle = getComputedStyle(document.documentElement);
let themeName = '';
Expand Down
95 changes: 95 additions & 0 deletions flow-client/src/main/frontend/ScreenOrientation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

type VaadinScreenOrientationType =
| 'portrait-primary'
| 'portrait-secondary'
| 'landscape-primary'
| 'landscape-secondary';

interface VaadinScreenOrientationDetail {
type: VaadinScreenOrientationType | '';
angle: number;
}

/**
* Returns the current screen orientation type synchronously, or
* {@code 'unsupported'} if the Screen Orientation API is unavailable. Used by
* the bootstrap path to seed the server-side signal without waiting for a DOM
* event.
*/
export function currentScreenOrientationType(): string {
return screen.orientation?.type ?? 'unsupported';
}

/**
* Returns the current screen orientation angle synchronously, or 0 if the
* Screen Orientation API is unavailable.
*/
export function currentScreenOrientationAngle(): number {
return screen.orientation?.angle ?? 0;
}

// Dispatch on document.body so the server-side Page facade (listening on the
// UI element, which is body) can update its signal.
function dispatch(detail: VaadinScreenOrientationDetail): void {
document.body.dispatchEvent(new CustomEvent('vaadin-screen-orientation-change', { detail }));
}

if (screen.orientation) {
screen.orientation.addEventListener('change', () => {
dispatch({
type: screen.orientation.type as VaadinScreenOrientationType,
angle: screen.orientation.angle
});
});
}

const $wnd = window as any;
$wnd.Vaadin ??= {};
$wnd.Vaadin.Flow ??= {};
interface VaadinScreenOrientationLockResult {
success: boolean;
name?: string;
message?: string;
}

$wnd.Vaadin.Flow.screenOrientation = {
// Always resolves so the server-side .then(success, error) chain only
// receives the "error" branch on a bridge failure (lost connection, etc.).
// Rejected DOMExceptions are folded into the resolved result so the server
// can decode them as a record without forfeiting the JS-bridge error arm.
lock(type: string): Promise<VaadinScreenOrientationLockResult> {
if (!screen.orientation || typeof screen.orientation.lock !== 'function') {
return Promise.resolve({
success: false,
name: 'NotSupportedError',
message: 'Screen Orientation API is not supported in this browser.'
});
}
return screen.orientation
.lock(type as OrientationLockType)
.then(() => ({ success: true }))
.catch((e: DOMException) => ({
success: false,
name: e.name ?? 'UnknownError',
message: e.message ?? ''
}));
},
unlock(): void {
screen.orientation?.unlock();
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,32 @@ public ColorScheme.Value getColorScheme() {
return colorScheme;
}

/**
* Returns whether the browser implements the <a href=
* "https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">Screen
* Orientation API</a>.
* <p>
* Mirrors the current state of {@link Page#screenOrientationSignal()}:
* {@code true} once the bootstrap has seeded the signal with a real
* orientation, {@code false} when the browser reports
* {@link ScreenOrientation#UNSUPPORTED} or before the client handshake has
* completed (signal is still {@link ScreenOrientation#UNKNOWN}). Lets
* callers decide synchronously whether to expose UI affordances that rely
* on the API (such as an orientation lock button) without subscribing to
* the signal first.
*
* @return {@code true} if the Screen Orientation API is available
*/
public boolean isScreenOrientationSupported() {
if (ui == null) {
return false;
}
ScreenOrientation type = ui.getPage().screenOrientationSignal().peek()
.type();
return type != ScreenOrientation.UNKNOWN
&& type != ScreenOrientation.UNSUPPORTED;
}

/**
* Gets the theme name.
*
Expand Down Expand Up @@ -499,6 +525,8 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) {
getStringElseNull.apply("v-tn"));
ui.getInternals().setExtendedClientDetails(details);
ui.getPage().setPageVisibility(getStringElseNull.apply("v-pv"));
ui.getPage().setScreenOrientation(getStringElseNull.apply("v-so"),
getStringElseNull.apply("v-soa"));
String ga = getStringElseNull.apply("v-ga");
if (ga != null) {
try {
Expand Down
192 changes: 192 additions & 0 deletions flow-server/src/main/java/com/vaadin/flow/component/page/Page.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import com.vaadin.flow.dom.DomListenerRegistration;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableRunnable;
import com.vaadin.flow.internal.UrlUtil;
import com.vaadin.flow.shared.Registration;
import com.vaadin.flow.shared.ui.Dependency;
Expand Down Expand Up @@ -66,6 +67,10 @@ public class Page implements Serializable {
PageVisibility.UNKNOWN);
private final Signal<PageVisibility> pageVisibilityReadOnly = pageVisibilitySignal
.asReadonly();
private final ValueSignal<ScreenOrientationData> screenOrientationSignal = new ValueSignal<>(
new ScreenOrientationData(ScreenOrientation.UNKNOWN, 0));
private final Signal<ScreenOrientationData> screenOrientationReadOnly = screenOrientationSignal
.asReadonly();

/**
* Creates a page instance for the given UI.
Expand All @@ -80,6 +85,14 @@ public Page(UI ui) {
.addEventListener("vaadin-page-visibility-change",
e -> setPageVisibility(e.getEventDetail(String.class)))
.addEventDetail().debounce(100).allowInert();
ui.getElement().addEventListener("vaadin-screen-orientation-change",
e -> setScreenOrientation(
e.getEventDetail(ScreenOrientationDetail.class)))
.addEventDetail().allowInert();
}

private record ScreenOrientationDetail(String type,
int angle) implements Serializable {
}

/**
Expand Down Expand Up @@ -490,6 +503,185 @@ private void ensureResizeListener() {
}
}

/**
* Returns a read-only signal that tracks the current screen orientation and
* its rotation angle.
* <p>
* The signal is seeded from the initial client bootstrap, so user code
* always sees a real value when the browser supports the <a href=
* "https://developer.mozilla.org/en-US/docs/Web/API/Screen_Orientation_API">Screen
* Orientation API</a>. Browsers that do not implement the API report
* {@link ScreenOrientation#UNSUPPORTED} after bootstrap; the initial value
* before bootstrap is {@link ScreenOrientation#UNKNOWN}. Once a real value
* has arrived, the signal never returns to {@code UNKNOWN}.
* <p>
* Subscribe with {@code Signal.effect(owner, ...)} to react to changes;
* call {@code screenOrientationSignal().peek()} for a snapshot outside a
* reactive context, and {@code .get()} inside one.
*
* @return the read-only screen orientation signal
*/
public Signal<ScreenOrientationData> screenOrientationSignal() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be getScreenOrientationSignal()
Also does the method name need the Signal at the end as the return type is Signal?

return screenOrientationReadOnly;
}

/**
* Locks the screen orientation to the given type for as long as the user
* remains on the current page. Most browsers require the document to be in
* fullscreen mode, and locking is generally only honored on devices where a
* physical orientation actually exists (mobile, tablet).
* <p>
* This overload is fire-and-forget: failures are logged at {@code DEBUG}
* but not otherwise surfaced. Use
* {@link #lockOrientation(ScreenOrientation, SerializableRunnable, SerializableConsumer)}
* to react to success or to the specific lock error.
*
* @param orientation
* the orientation to lock to, not {@code null} and not
* {@link ScreenOrientation#UNKNOWN} or
* {@link ScreenOrientation#UNSUPPORTED}
*/
public void lockOrientation(ScreenOrientation orientation) {
lockOrientation(orientation, () -> {
}, error -> LOGGER.debug("Screen orientation lock failed: {} ({})",
error.message(), error.name()));
}

/**
* Locks the screen orientation to the given type and notifies the matching
* callback when the browser resolves the request. Mirrors the
* {@link com.vaadin.flow.component.geolocation.Geolocation#getPosition
* Geolocation.getPosition} pattern so applications can bind UI to lock
* success and failure without having to write JavaScript glue.
* <p>
* The browser dispatches exactly one of the two callbacks on the UI thread.
* A lock typically requires fullscreen and a device that physically rotates
* — see {@link ScreenOrientationLockError} for the {@code DOMException}
* names you can expect on failure.
*
* @param orientation
* the orientation to lock to, not {@code null} and not
* {@link ScreenOrientation#UNKNOWN} or
* {@link ScreenOrientation#UNSUPPORTED}
* @param onSuccess
* invoked when the browser confirms the lock; not {@code null}
* @param onError
* invoked when the browser rejects the request, or when the
* Screen Orientation API is not available; not {@code null}
*/
public void lockOrientation(ScreenOrientation orientation,
SerializableRunnable onSuccess,
SerializableConsumer<ScreenOrientationLockError> onError) {
Objects.requireNonNull(orientation, "orientation cannot be null");
Objects.requireNonNull(onSuccess, "onSuccess callback cannot be null");
Objects.requireNonNull(onError, "onError callback cannot be null");
if (orientation == ScreenOrientation.UNKNOWN
|| orientation == ScreenOrientation.UNSUPPORTED) {
throw new IllegalArgumentException(
"Cannot lock to ScreenOrientation." + orientation.name());
}
ui.getElement()
.executeJs(
"return window.Vaadin.Flow.screenOrientation.lock($0)",
orientation.getClientValue())
.then(LockResult.class, result -> {
if (result.success()) {
onSuccess.run();
} else {
onError.accept(new ScreenOrientationLockError(
result.name() == null ? "UnknownError"
: result.name(),
result.message() == null ? ""
: result.message()));
}
}, bridgeError -> onError.accept(new ScreenOrientationLockError(
"BridgeError", bridgeError)));
}

private record LockResult(boolean success, String name,
String message) implements Serializable {
}

/**
* Releases a previous
* {@link #lockOrientation(ScreenOrientation, SerializableRunnable, SerializableConsumer)
* lock}, allowing the screen to follow the device orientation again. A
* no-op on browsers that do not implement the Screen Orientation API.
* <p>
* Fire-and-forget: use {@link #unlockOrientation(SerializableRunnable)} to
* be notified when the browser has applied the unlock.
*/
public void unlockOrientation() {
executeJs("window.Vaadin.Flow.screenOrientation.unlock()");
}

/**
* Releases a previous
* {@link #lockOrientation(ScreenOrientation, SerializableRunnable, SerializableConsumer)
* lock} and notifies the given callback after the browser has applied the
* unlock. A no-op (but the callback still fires) on browsers that do not
* implement the Screen Orientation API.
* <p>
* Mirrors the callback shape of
* {@link #lockOrientation(ScreenOrientation, SerializableRunnable, SerializableConsumer)}
* so cleanup flows ("leaving fullscreen — am I fully unlocked yet?") can be
* sequenced reactively rather than assuming the unlock has landed.
*
* @param onComplete
* invoked on the UI thread once the unlock round-trip has
* completed; not {@code null}
*/
public void unlockOrientation(SerializableRunnable onComplete) {
Objects.requireNonNull(onComplete,
"onComplete callback cannot be null");
executeJs("window.Vaadin.Flow.screenOrientation.unlock()")
.then(ignored -> onComplete.run());
}

/**
* Sets the screen orientation from raw client-side values (e.g. from the
* bootstrap parameters). {@code null} type means the bootstrap parameters
* are absent (e.g. in a unit-test scenario) and the previous value is
* preserved. The client reports {@code "unsupported"} when the browser does
* not implement the Screen Orientation API, which maps to
* {@link ScreenOrientation#UNSUPPORTED}. Unknown type values are logged at
* debug level so a forward-compatible client value does not silently
* disappear.
*
* @param type
* the raw orientation type from the client, or {@code null}
* @param angle
* the raw orientation angle from the client, or {@code null}
*/
void setScreenOrientation(String type, String angle) {
if (type == null || type.isEmpty()) {
return;
}
try {
int angleValue = angle == null ? 0 : Integer.parseInt(angle);
screenOrientationSignal.set(new ScreenOrientationData(
ScreenOrientation.fromClientValue(type), angleValue));
} catch (IllegalArgumentException e) {
LOGGER.debug("Unknown screen orientation value from client: "
+ "type={} angle={}", type, angle);
}
}

private void setScreenOrientation(ScreenOrientationDetail detail) {
if (detail == null || detail.type() == null
|| detail.type().isEmpty()) {
return;
}
try {
screenOrientationSignal.set(new ScreenOrientationData(
ScreenOrientation.fromClientValue(detail.type()),
detail.angle()));
} catch (IllegalArgumentException e) {
LOGGER.debug("Unknown screen orientation value from client: {}",
detail.type());
}
}

/**
* Returns a read-only signal that tracks the browser tab's visibility and
* focus state.
Expand Down
Loading
Loading