diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 61cb614b28a..dcb0b536c9a 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -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; @@ -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 = ''; diff --git a/flow-client/src/main/frontend/ScreenOrientation.ts b/flow-client/src/main/frontend/ScreenOrientation.ts new file mode 100644 index 00000000000..b406177f2c1 --- /dev/null +++ b/flow-client/src/main/frontend/ScreenOrientation.ts @@ -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 { + 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(); + } +}; diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java index 81a5ca253b0..78bf8d6ed68 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ExtendedClientDetails.java @@ -421,6 +421,32 @@ public ColorScheme.Value getColorScheme() { return colorScheme; } + /** + * Returns whether the browser implements the Screen + * Orientation API. + *

+ * 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. * @@ -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 { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java index c9b6ac5c482..73c597a05a8 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java @@ -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; @@ -66,6 +67,10 @@ public class Page implements Serializable { PageVisibility.UNKNOWN); private final Signal pageVisibilityReadOnly = pageVisibilitySignal .asReadonly(); + private final ValueSignal screenOrientationSignal = new ValueSignal<>( + new ScreenOrientationData(ScreenOrientation.UNKNOWN, 0)); + private final Signal screenOrientationReadOnly = screenOrientationSignal + .asReadonly(); /** * Creates a page instance for the given UI. @@ -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 { } /** @@ -490,6 +503,185 @@ private void ensureResizeListener() { } } + /** + * Returns a read-only signal that tracks the current screen orientation and + * its rotation angle. + *

+ * The signal is seeded from the initial client bootstrap, so user code + * always sees a real value when the browser supports the Screen + * Orientation API. 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}. + *

+ * 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 screenOrientationSignal() { + 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). + *

+ * 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. + *

+ * 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 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. + *

+ * 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. + *

+ * 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. diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientation.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientation.java new file mode 100644 index 00000000000..fb92f1fbe6b --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientation.java @@ -0,0 +1,128 @@ +/* + * 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. + */ +package com.vaadin.flow.component.page; + +/** + * Represents the orientation of the browser screen. + *

+ * Mirrors the values reported by the browser's Screen + * Orientation API, plus an {@link #UNKNOWN} sentinel used before the first + * value has arrived from the client and an {@link #UNSUPPORTED} sentinel for + * browsers that do not implement the API. + * + * @see Page#screenOrientationSignal() + */ +public enum ScreenOrientation { + + /** + * No value has been reported by the browser yet. Used as the initial value + * of the signal before the first client handshake delivers a real one. In + * normal request handling the signal is seeded before any user code runs, + * so this value is essentially never observed in practice; once a real + * value has arrived, the signal never returns to {@code UNKNOWN}. + */ + UNKNOWN(""), + + /** + * The browser does not implement the Screen + * Orientation API. Distinct from {@link #UNKNOWN} so callers can tell + * "no data yet" apart from "the platform will never produce data." + */ + UNSUPPORTED("unsupported"), + + /** + * The screen is in primary portrait orientation (the device is held upright + * in its natural portrait position). + */ + PORTRAIT_PRIMARY("portrait-primary"), + + /** + * The screen is in secondary portrait orientation (the device is rotated + * 180° from {@link #PORTRAIT_PRIMARY}). + */ + PORTRAIT_SECONDARY("portrait-secondary"), + + /** + * The screen is in primary landscape orientation (the device is rotated 90° + * clockwise from its natural portrait position). + */ + LANDSCAPE_PRIMARY("landscape-primary"), + + /** + * The screen is in secondary landscape orientation (the device is rotated + * 90° counter-clockwise from its natural portrait position). + */ + LANDSCAPE_SECONDARY("landscape-secondary"); + + private final String clientValue; + + ScreenOrientation(String clientValue) { + this.clientValue = clientValue; + } + + /** + * Returns the value as used by the browser's Screen Orientation API. + * + * @return the client-side orientation type string + */ + public String getClientValue() { + return clientValue; + } + + /** + * Returns {@code true} for the two landscape orientations + * ({@link #LANDSCAPE_PRIMARY}, {@link #LANDSCAPE_SECONDARY}). + * {@link #UNKNOWN} and {@link #UNSUPPORTED} return {@code false}. + * + * @return whether this is a landscape orientation + */ + public boolean isLandscape() { + return this == LANDSCAPE_PRIMARY || this == LANDSCAPE_SECONDARY; + } + + /** + * Returns {@code true} for the two portrait orientations + * ({@link #PORTRAIT_PRIMARY}, {@link #PORTRAIT_SECONDARY}). + * {@link #UNKNOWN} and {@link #UNSUPPORTED} return {@code false}. + * + * @return whether this is a portrait orientation + */ + public boolean isPortrait() { + return this == PORTRAIT_PRIMARY || this == PORTRAIT_SECONDARY; + } + + /** + * Returns the enum constant matching the given client-side orientation type + * string. + * + * @param clientValue + * the orientation type string from the browser + * @return the corresponding enum value + * @throws IllegalArgumentException + * if the value does not match any known orientation type + */ + public static ScreenOrientation fromClientValue(String clientValue) { + for (ScreenOrientation orientation : values()) { + if (orientation.clientValue.equals(clientValue)) { + return orientation; + } + } + throw new IllegalArgumentException( + "Unknown screen orientation type: " + clientValue); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationData.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationData.java new file mode 100644 index 00000000000..c1fc6017251 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationData.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +package com.vaadin.flow.component.page; + +import java.io.Serializable; + +/** + * Represents the current screen orientation state, including the orientation + * type and the angle of rotation. + * + * @param type + * the screen orientation type + * @param angle + * the screen orientation angle in degrees + * + * @author Vaadin Ltd + */ +public record ScreenOrientationData(ScreenOrientation type, + int angle) implements Serializable { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationLockError.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationLockError.java new file mode 100644 index 00000000000..d42a72ebefb --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationLockError.java @@ -0,0 +1,42 @@ +/* + * 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. + */ +package com.vaadin.flow.component.page; + +import java.io.Serializable; + +/** + * Describes a failed screen-orientation lock request. + *

+ * Fields mirror the {@code DOMException} the browser rejects + * {@code screen.orientation.lock()} with. The most common values for + * {@code name} are: + *

+ * + * @param name + * the {@code DOMException} name, e.g. {@code "SecurityError"} + * @param message + * the {@code DOMException} message, suitable for logging + */ +public record ScreenOrientationLockError(String name, + String message) implements Serializable { +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageScreenOrientationTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageScreenOrientationTest.java new file mode 100644 index 00000000000..31cd86f1db0 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageScreenOrientationTest.java @@ -0,0 +1,286 @@ +/* + * 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. + */ +package com.vaadin.flow.component.page; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.signals.local.ValueSignal; +import com.vaadin.tests.util.MockUI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class PageScreenOrientationTest { + + @Test + void screenOrientationSignal_isReadOnly() { + MockUI ui = new MockUI(); + Signal signal = ui.getPage() + .screenOrientationSignal(); + assertFalse(signal instanceof ValueSignal, + "screenOrientationSignal() should return a read-only signal"); + } + + @Test + void screenOrientationSignal_defaultsToUnknownBeforeBootstrap() { + MockUI ui = new MockUI(); + Signal signal = ui.getPage() + .screenOrientationSignal(); + assertEquals(ScreenOrientation.UNKNOWN, signal.peek().type(), + "Before bootstrap the type should be UNKNOWN so callers can " + + "distinguish 'no data yet' from a real value"); + assertEquals(0, signal.peek().angle()); + } + + @Test + void screenOrientationSignal_readonlyWrapperIsCached() { + Page page = new MockUI().getPage(); + assertSame(page.screenOrientationSignal(), + page.screenOrientationSignal(), + "Repeated calls must return the same read-only wrapper so " + + "subscriber identity stays stable"); + } + + @Test + void screenOrientationSignal_tracksOrientationChanges() { + MockUI ui = new MockUI(); + Signal signal = ui.getPage() + .screenOrientationSignal(); + + fireOrientationEvent(ui, "landscape-primary", 90); + assertEquals(ScreenOrientation.LANDSCAPE_PRIMARY, signal.peek().type()); + assertEquals(90, signal.peek().angle()); + + fireOrientationEvent(ui, "portrait-secondary", 180); + assertEquals(ScreenOrientation.PORTRAIT_SECONDARY, + signal.peek().type()); + assertEquals(180, signal.peek().angle()); + } + + @Test + void screenOrientationSignal_unknownTypeKeepsPreviousValue() { + MockUI ui = new MockUI(); + Signal signal = ui.getPage() + .screenOrientationSignal(); + + fireOrientationEvent(ui, "landscape-primary", 90); + fireOrientationEvent(ui, "diagonal-future", 45); + + assertEquals(ScreenOrientation.LANDSCAPE_PRIMARY, signal.peek().type(), + "Unknown type values from a newer client should not reset " + + "the signal"); + assertEquals(90, signal.peek().angle()); + } + + @Test + void setScreenOrientation_fromBootstrapSeedsSignal() { + MockUI ui = new MockUI(); + ui.getPage().setScreenOrientation("landscape-secondary", "270"); + + ScreenOrientationData data = ui.getPage().screenOrientationSignal() + .peek(); + assertEquals(ScreenOrientation.LANDSCAPE_SECONDARY, data.type()); + assertEquals(270, data.angle()); + } + + @Test + void setScreenOrientation_emptyTypeIsIgnored() { + MockUI ui = new MockUI(); + ui.getPage().setScreenOrientation("", "0"); + assertEquals(ScreenOrientation.UNKNOWN, + ui.getPage().screenOrientationSignal().peek().type(), + "Empty type from a browser without the Screen Orientation API " + + "must keep UNKNOWN, not crash"); + } + + @Test + void lockOrientation_executesCorrectJs() { + MockUI ui = new MockUI(); + ui.getPage().lockOrientation(ScreenOrientation.LANDSCAPE_PRIMARY); + + List invocations = ui + .dumpPendingJsInvocations(); + assertTrue(invocations.stream() + .anyMatch(i -> i.getInvocation().getExpression().contains( + "window.Vaadin.Flow.screenOrientation.lock("))); + } + + @Test + void lockOrientation_unknownIsRejected() { + Page page = new MockUI().getPage(); + assertThrows(IllegalArgumentException.class, + () -> page.lockOrientation(ScreenOrientation.UNKNOWN)); + } + + @Test + void lockOrientation_unsupportedIsRejected() { + Page page = new MockUI().getPage(); + assertThrows(IllegalArgumentException.class, + () -> page.lockOrientation(ScreenOrientation.UNSUPPORTED)); + } + + @Test + void lockOrientation_successCallbackFires() { + MockUI ui = new MockUI(); + AtomicBoolean success = new AtomicBoolean(); + ui.getPage().lockOrientation(ScreenOrientation.LANDSCAPE_PRIMARY, + () -> success.set(true), + error -> fail("onError should not fire: " + error.message())); + + ObjectNode result = JacksonUtils.createObjectNode(); + result.put("success", true); + resolveLockPromise(ui, result); + + assertTrue(success.get(), + "onSuccess must fire when the client resolves with success"); + } + + @Test + void lockOrientation_errorCallbackFires() { + MockUI ui = new MockUI(); + AtomicReference captured = new AtomicReference<>(); + ui.getPage().lockOrientation(ScreenOrientation.LANDSCAPE_PRIMARY, + () -> fail("onSuccess should not fire"), captured::set); + + ObjectNode result = JacksonUtils.createObjectNode(); + result.put("success", false); + result.put("name", "SecurityError"); + result.put("message", "Must be in fullscreen"); + resolveLockPromise(ui, result); + + assertEquals("SecurityError", captured.get().name()); + assertEquals("Must be in fullscreen", captured.get().message()); + } + + @Test + void unlockOrientation_executesCorrectJs() { + MockUI ui = new MockUI(); + ui.getPage().unlockOrientation(); + + List invocations = ui + .dumpPendingJsInvocations(); + assertTrue(invocations.stream() + .anyMatch(i -> i.getInvocation().getExpression().contains( + "window.Vaadin.Flow.screenOrientation.unlock()"))); + } + + @Test + void unlockOrientation_completionCallbackFires() { + MockUI ui = new MockUI(); + AtomicBoolean done = new AtomicBoolean(); + ui.getPage().unlockOrientation(() -> done.set(true)); + + PendingJavaScriptInvocation invocation = ui.dumpPendingJsInvocations() + .stream() + .filter(inv -> inv.getInvocation().getExpression() + .contains("screenOrientation.unlock")) + .reduce((a, b) -> b).orElseThrow(); + invocation.complete(JacksonUtils.nullNode()); + + assertTrue(done.get(), + "onComplete must fire once the unlock round-trip resolves"); + } + + @Test + void screenOrientation_fromClientValue() { + assertEquals(ScreenOrientation.PORTRAIT_PRIMARY, + ScreenOrientation.fromClientValue("portrait-primary")); + assertEquals(ScreenOrientation.LANDSCAPE_SECONDARY, + ScreenOrientation.fromClientValue("landscape-secondary")); + assertEquals(ScreenOrientation.UNSUPPORTED, + ScreenOrientation.fromClientValue("unsupported")); + assertThrows(IllegalArgumentException.class, + () -> ScreenOrientation.fromClientValue("diagonal-future")); + } + + @Test + void isLandscape_isPortrait() { + assertTrue(ScreenOrientation.LANDSCAPE_PRIMARY.isLandscape()); + assertTrue(ScreenOrientation.LANDSCAPE_SECONDARY.isLandscape()); + assertFalse(ScreenOrientation.PORTRAIT_PRIMARY.isLandscape()); + assertFalse(ScreenOrientation.UNKNOWN.isLandscape()); + assertFalse(ScreenOrientation.UNSUPPORTED.isLandscape()); + + assertTrue(ScreenOrientation.PORTRAIT_PRIMARY.isPortrait()); + assertTrue(ScreenOrientation.PORTRAIT_SECONDARY.isPortrait()); + assertFalse(ScreenOrientation.LANDSCAPE_PRIMARY.isPortrait()); + assertFalse(ScreenOrientation.UNKNOWN.isPortrait()); + assertFalse(ScreenOrientation.UNSUPPORTED.isPortrait()); + } + + @Test + void setScreenOrientation_unsupportedFromBootstrap() { + MockUI ui = new MockUI(); + ui.getPage().setScreenOrientation("unsupported", "0"); + assertEquals(ScreenOrientation.UNSUPPORTED, + ui.getPage().screenOrientationSignal().peek().type(), + "Client-side 'unsupported' must be observable distinctly " + + "from the pre-bootstrap UNKNOWN state"); + } + + @Test + void isScreenOrientationSupported_reflectsSignalState() { + MockUI ui = new MockUI(); + ExtendedClientDetails details = ui.getPage().getExtendedClientDetails(); + + assertFalse(details.isScreenOrientationSupported(), + "Before bootstrap (UNKNOWN) the feature-detect must be " + + "false so callers don't expose unusable UI"); + + ui.getPage().setScreenOrientation("unsupported", "0"); + assertFalse(details.isScreenOrientationSupported(), + "UNSUPPORTED bootstrap value must yield false"); + + ui.getPage().setScreenOrientation("landscape-primary", "90"); + assertTrue(details.isScreenOrientationSupported(), + "A real orientation from the bootstrap means the API is " + + "available"); + } + + private static void resolveLockPromise(MockUI ui, ObjectNode result) { + PendingJavaScriptInvocation invocation = ui.dumpPendingJsInvocations() + .stream() + .filter(inv -> inv.getInvocation().getExpression() + .contains("screenOrientation.lock")) + .reduce((a, b) -> b).orElseThrow(); + invocation.complete(result); + } + + private void fireOrientationEvent(MockUI ui, String type, int angle) { + ObjectNode detail = JacksonUtils.createObjectNode(); + detail.put("type", type); + detail.put("angle", angle); + ObjectNode eventData = JacksonUtils.createObjectNode(); + eventData.set("event.detail", detail); + ui.getElement().getNode().getFeature(ElementListenerMap.class) + .fireEvent(new DomEvent(ui.getElement(), + "vaadin-screen-orientation-change", eventData)); + } +} diff --git a/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ScreenOrientationView.java b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ScreenOrientationView.java new file mode 100644 index 00000000000..3ce1f7355be --- /dev/null +++ b/flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ScreenOrientationView.java @@ -0,0 +1,51 @@ +/* + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import java.util.concurrent.atomic.AtomicInteger; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.page.ScreenOrientationData; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.signals.Signal; +import com.vaadin.flow.uitest.servlet.ViewTestLayout; + +@Route(value = "com.vaadin.flow.uitest.ui.ScreenOrientationView", layout = ViewTestLayout.class) +public class ScreenOrientationView extends AbstractDivView { + + @Override + protected void onShow() { + Div type = new Div(); + type.setId("type"); + Div angle = new Div(); + angle.setId("angle"); + Div updates = new Div(); + updates.setId("updates"); + updates.setText("0"); + add(type, angle, updates); + + Signal signal = UI.getCurrent().getPage() + .screenOrientationSignal(); + AtomicInteger count = new AtomicInteger(); + Signal.effect(this, () -> { + ScreenOrientationData data = signal.get(); + type.setText(data.type().name()); + angle.setText(String.valueOf(data.angle())); + updates.setText(String.valueOf(count.incrementAndGet())); + }); + } +} diff --git a/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/ScreenOrientationIT.java b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/ScreenOrientationIT.java new file mode 100644 index 00000000000..917fe6fd12e --- /dev/null +++ b/flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/ScreenOrientationIT.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package com.vaadin.flow.uitest.ui; + +import org.junit.Test; +import org.openqa.selenium.By; + +import com.vaadin.flow.testutil.ChromeBrowserTest; + +public class ScreenOrientationIT extends ChromeBrowserTest { + + @Test + public void initialState_isReportedFromBootstrap() { + open(); + // The bootstrap parameters v-so/v-soa must seed the signal to a real + // value before the view renders. Headless Chrome reports a portrait + // orientation by default; the contract is only that the type is no + // longer UNKNOWN. + waitUntil(d -> { + String t = findElement(By.id("type")).getText(); + return t.startsWith("PORTRAIT") || t.startsWith("LANDSCAPE"); + }); + } + + @Test + public void orientationChange_isPropagatedToSignal() { + open(); + + // Fake a change event by overriding screen.orientation and dispatching + // the change event the client listener subscribes to. Headless Chrome + // does not actually rotate, so the values are spoofed. + executeScript(""" + Object.defineProperty(screen.orientation, 'type', \ + {value: 'landscape-primary', configurable: true}); + Object.defineProperty(screen.orientation, 'angle', \ + {value: 90, configurable: true}); + screen.orientation.dispatchEvent(new Event('change')); + """); + + waitUntil(d -> "LANDSCAPE_PRIMARY" + .equals(findElement(By.id("type")).getText()) + && "90".equals(findElement(By.id("angle")).getText())); + } +}