From ed79c395707ce993de142591a08baa0b7d249e4d Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Sat, 21 Feb 2026 18:42:58 +0000 Subject: [PATCH 1/3] feat: Add Screen Orientation API support to Page Expose the browser Screen Orientation API through Page methods, providing a read-only signal for tracking orientation changes and methods to lock/unlock screen orientation for tablet and mobile apps. --- flow-client/src/main/frontend/Flow.ts | 6 + .../src/main/frontend/ScreenOrientation.ts | 70 +++++++ .../component/page/ExtendedClientDetails.java | 2 + .../com/vaadin/flow/component/page/Page.java | 108 +++++++++++ .../component/page/ScreenOrientation.java | 98 ++++++++++ .../component/page/ScreenOrientationData.java | 33 ++++ .../page/PageScreenOrientationTest.java | 171 ++++++++++++++++++ .../flow/uitest/ui/ScreenOrientationView.java | 51 ++++++ .../flow/uitest/ui/ScreenOrientationIT.java | 57 ++++++ 9 files changed, 596 insertions(+) create mode 100644 flow-client/src/main/frontend/ScreenOrientation.ts create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientation.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationData.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/component/page/PageScreenOrientationTest.java create mode 100644 flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/ScreenOrientationView.java create mode 100644 flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/ScreenOrientationIT.java 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..b2366c98180 --- /dev/null +++ b/flow-client/src/main/frontend/ScreenOrientation.ts @@ -0,0 +1,70 @@ +/* + * 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 the empty + * string 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 ?? ''; +} + +/** + * 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 ??= {}; +$wnd.Vaadin.Flow.screenOrientation = { + lock(type: string): Promise { + return screen.orientation.lock(type as OrientationLockType); + }, + 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..39aede36469 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 @@ -499,6 +499,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..4c4f97fa001 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 @@ -66,6 +66,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 +84,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 +502,102 @@ 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. On browsers that do not implement the API (or until + * the first bootstrap arrives) the value is + * {@link ScreenOrientation#UNKNOWN} with angle 0; 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). + *

+ * The returned result resolves when the lock succeeds, or completes + * exceptionally if the browser denies the request. + * + * @param orientation + * the orientation to lock to, not {@code null} and not + * {@link ScreenOrientation#UNKNOWN} + * @return a pending result that resolves when the lock is applied + */ + public PendingJavaScriptResult lockOrientation( + ScreenOrientation orientation) { + Objects.requireNonNull(orientation); + if (orientation == ScreenOrientation.UNKNOWN) { + throw new IllegalArgumentException( + "Cannot lock to ScreenOrientation.UNKNOWN"); + } + return executeJs("return window.Vaadin.Flow.screenOrientation.lock($0)", + orientation.getClientValue()); + } + + /** + * Releases a previous {@link #lockOrientation(ScreenOrientation) lock}, + * allowing the screen to follow the device orientation again. + */ + public void unlockOrientation() { + executeJs("window.Vaadin.Flow.screenOrientation.unlock()"); + } + + /** + * Sets the screen orientation from raw client-side values (e.g. from the + * bootstrap parameters). {@code null} or empty type means the browser does + * not implement the Screen Orientation API and the previous value is + * preserved. 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..54f0f4a249c --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientation.java @@ -0,0 +1,98 @@ +/* + * 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 on browsers that do not implement the + * API. + * + * @see Page#screenOrientationSignal() + */ +public enum ScreenOrientation { + + /** + * No value has been reported by the browser yet, or the browser does not + * implement the Screen Orientation API. 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 observed only when the API itself is unavailable. + */ + UNKNOWN(""), + + /** + * 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 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/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..3a4c421bb1d --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageScreenOrientationTest.java @@ -0,0 +1,171 @@ +/* + * 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 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; + +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 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 screenOrientation_fromClientValue() { + assertEquals(ScreenOrientation.PORTRAIT_PRIMARY, + ScreenOrientation.fromClientValue("portrait-primary")); + assertEquals(ScreenOrientation.LANDSCAPE_SECONDARY, + ScreenOrientation.fromClientValue("landscape-secondary")); + assertThrows(IllegalArgumentException.class, + () -> ScreenOrientation.fromClientValue("unknown")); + } + + 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())); + } +} From a9a453c96e03e8983094d169efc2eba288868fa8 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Mon, 11 May 2026 09:29:56 +0000 Subject: [PATCH 2/3] feat: Add isLandscape/isPortrait, UNSUPPORTED state, and lock callbacks Addresses three API gaps surfaced while building use cases against the screen orientation API: - Add ScreenOrientation.isLandscape() / isPortrait() so adaptive layouts do not have to spell out the two-value disjunction. - Add ScreenOrientation.UNSUPPORTED, distinct from UNKNOWN: the client now reports "unsupported" from the bootstrap when screen.orientation is absent, so callers can tell "no data yet" from "the platform will never produce data." - Replace lockOrientation's PendingJavaScriptResult return with the Geolocation.getPosition-style onSuccess/onError callbacks, plus a ScreenOrientationLockError record carrying the DOMException name and message. Rejected lock requests now surface reactively instead of vanishing unless the caller chained .then(...) themselves. A fire-and-forget single-arg overload logs failures at DEBUG. --- .../src/main/frontend/ScreenOrientation.ts | 39 +++++-- .../com/vaadin/flow/component/page/Page.java | 100 ++++++++++++++---- .../component/page/ScreenOrientation.java | 44 ++++++-- .../page/ScreenOrientationLockError.java | 42 ++++++++ .../page/PageScreenOrientationTest.java | 81 +++++++++++++- 5 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/page/ScreenOrientationLockError.java diff --git a/flow-client/src/main/frontend/ScreenOrientation.ts b/flow-client/src/main/frontend/ScreenOrientation.ts index b2366c98180..b406177f2c1 100644 --- a/flow-client/src/main/frontend/ScreenOrientation.ts +++ b/flow-client/src/main/frontend/ScreenOrientation.ts @@ -26,12 +26,13 @@ interface VaadinScreenOrientationDetail { } /** - * Returns the current screen orientation type synchronously, or the empty - * string if the Screen Orientation API is unavailable. Used by the bootstrap - * path to seed the server-side signal without waiting for a DOM event. + * 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 ?? ''; + return screen.orientation?.type ?? 'unsupported'; } /** @@ -60,11 +61,35 @@ if (screen.orientation) { const $wnd = window as any; $wnd.Vaadin ??= {}; $wnd.Vaadin.Flow ??= {}; +interface VaadinScreenOrientationLockResult { + success: boolean; + name?: string; + message?: string; +} + $wnd.Vaadin.Flow.screenOrientation = { - lock(type: string): Promise { - return screen.orientation.lock(type as OrientationLockType); + // 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(); + screen.orientation?.unlock(); } }; 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 4c4f97fa001..94ba49d7b18 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; @@ -509,10 +510,10 @@ private void ensureResizeListener() { * 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. On browsers that do not implement the API (or until - * the first bootstrap arrives) the value is - * {@link ScreenOrientation#UNKNOWN} with angle 0; once a real value has - * arrived, the signal never returns to {@code UNKNOWN}. + * 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 @@ -530,28 +531,82 @@ public Signal screenOrientationSignal() { * fullscreen mode, and locking is generally only honored on devices where a * physical orientation actually exists (mobile, tablet). *

- * The returned result resolves when the lock succeeds, or completes - * exceptionally if the browser denies the request. + * 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} - * @return a pending result that resolves when the lock is applied + * {@link ScreenOrientation#UNKNOWN} or + * {@link ScreenOrientation#UNSUPPORTED} */ - public PendingJavaScriptResult lockOrientation( - ScreenOrientation orientation) { - Objects.requireNonNull(orientation); - if (orientation == ScreenOrientation.UNKNOWN) { + 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.UNKNOWN"); + "Cannot lock to ScreenOrientation." + orientation.name()); } - return executeJs("return window.Vaadin.Flow.screenOrientation.lock($0)", - orientation.getClientValue()); + 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) lock}, - * allowing the screen to follow the device orientation again. + * 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. */ public void unlockOrientation() { executeJs("window.Vaadin.Flow.screenOrientation.unlock()"); @@ -559,10 +614,13 @@ public void unlockOrientation() { /** * Sets the screen orientation from raw client-side values (e.g. from the - * bootstrap parameters). {@code null} or empty type means the browser does - * not implement the Screen Orientation API and the previous value is - * preserved. Unknown type values are logged at debug level so a - * forward-compatible client value does not silently disappear. + * 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} 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 index 54f0f4a249c..fb92f1fbe6b 100644 --- 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 @@ -21,22 +21,30 @@ * 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 on browsers that do not implement the - * API. + * 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, or the browser does not - * implement the Screen Orientation API. 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 observed only when the API itself is unavailable. + * 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). @@ -76,6 +84,28 @@ 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. 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: + *

    + *
  • {@code "NotSupportedError"} — the browser does not implement the Screen + * Orientation API at all, or does not allow locking on this device.
  • + *
  • {@code "SecurityError"} — the document is not in fullscreen, which most + * browsers require for locking.
  • + *
  • {@code "AbortError"} — a newer lock or unlock call superseded this + * one.
  • + *
+ * + * @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 index 3a4c421bb1d..53cf4729ebd 100644 --- 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 @@ -16,6 +16,8 @@ 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; @@ -33,6 +35,7 @@ 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 { @@ -136,6 +139,46 @@ void lockOrientation_unknownIsRejected() { () -> 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(); @@ -154,8 +197,44 @@ void screenOrientation_fromClientValue() { ScreenOrientation.fromClientValue("portrait-primary")); assertEquals(ScreenOrientation.LANDSCAPE_SECONDARY, ScreenOrientation.fromClientValue("landscape-secondary")); + assertEquals(ScreenOrientation.UNSUPPORTED, + ScreenOrientation.fromClientValue("unsupported")); assertThrows(IllegalArgumentException.class, - () -> ScreenOrientation.fromClientValue("unknown")); + () -> 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"); + } + + 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) { From f8962436f1af82625eec57f5be0fd8f9a4586e16 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Wed, 13 May 2026 12:37:30 +0000 Subject: [PATCH 3/3] feat: Add screen-orientation feature-detect and unlock completion callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ExtendedClientDetails#isScreenOrientationSupported() for a synchronous feature-detect (mirrors the Page screen-orientation signal) and a Page#unlockOrientation(SerializableRunnable) overload that fires once the unlock round-trip completes — symmetric with the lockOrientation callbacks. --- .../component/page/ExtendedClientDetails.java | 26 ++++++++++++++ .../com/vaadin/flow/component/page/Page.java | 26 ++++++++++++++ .../page/PageScreenOrientationTest.java | 36 +++++++++++++++++++ 3 files changed, 88 insertions(+) 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 39aede36469..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. * 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 94ba49d7b18..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 @@ -607,11 +607,37 @@ private record LockResult(boolean success, String name, * {@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 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 index 53cf4729ebd..31cd86f1db0 100644 --- 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 @@ -191,6 +191,23 @@ void unlockOrientation_executesCorrectJs() { "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, @@ -228,6 +245,25 @@ void setScreenOrientation_unsupportedFromBootstrap() { + "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()