From 3685317c548cac59165ec36eaf55b840af31948d Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Fri, 12 Jun 2026 16:59:53 +0300 Subject: [PATCH] feat: Add Web Share API support (#24325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a `WebShare` facade for the browser's [Web Share API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API): `WebShare.onClick(button).share(ShareContent.create().title("...").text("...").url("..."))` opens the native share sheet when the user clicks, optionally reporting the outcome back to the server through `onShared`/`onError` callbacks. - The share call runs through the trigger/action framework (a new internal `ShareAction` extending `PromiseAction`), so `navigator.share()` executes inside the client-side click handler and inherits the transient user gesture the API requires — a server round-trip via `Page.executeJs()` would lose the activation and the browser would reject the call. - `WebShare.supportSignal()` exposes feature detection as a read-only signal, seeded during the bootstrap handshake via a new `v-ws` parameter (`Flow.ts` → `ExtendedClientDetails` → a `ValueSignal` on `UIInternals`), so views can decide whether to show a share affordance before any view code runs. ## Details - `ShareContent` is a builder with `title`/`text`/`url` slots; each slot accepts either a string literal or a `HasValue` component, in which case the component's `value` property is read on the client at gesture time. At least one slot must be set — the Web Share API rejects an empty payload, so this is validated server-side. - `onError` deliberately fires for the `AbortError` the browser reports when the user dismisses the share sheet without picking a target, not only for true failures; this matches browser semantics and is documented on the callback. - The support signal mirrors the existing geolocation/wake-lock pattern: `UNKNOWN` only before the first handshake, then a stable `SUPPORTED`/`UNSUPPORTED` for the rest of the session. The read-only wrapper is cached on `UIInternals` for stable identity. - Unit tests cover the rendered `navigator.share({...})` payload composition (literal and property-backed slots, partial payloads, promise observation wrapping) and the signal seeding. ## API Changes: feature/web-share vs origin/main 6 classes affected — 5 new public types, 2 methods added to an existing class. ### com.vaadin.flow.component.internal.UIInternals ```java // Added public Signal getWebShareSupportSignalReadOnly() public void setWebShareSupport(WebShareSupport support) // framework use only ``` ### com.vaadin.flow.component.trigger.internal.ShareAction ```java // Added public class ShareAction extends PromiseAction // internal trigger-framework action public ShareAction(Action.@Nullable Input titleInput, Action.@Nullable Input textInput, Action.@Nullable Input urlInput) public ShareAction(Action.@Nullable Input titleInput, Action.@Nullable Input textInput, Action.@Nullable Input urlInput, SerializableRunnable onShared, SerializableConsumer onError) protected JsFunction toPromiseJs(Trigger trigger) ``` ### com.vaadin.flow.component.webshare.ShareContent ```java // Added public final class ShareContent implements Serializable public static ShareContent create() public ShareContent title(String literal) public > ShareContent title(C source) public ShareContent text(String literal) public > ShareContent text(C source) public ShareContent url(String literal) public > ShareContent url(C source) ``` ### com.vaadin.flow.component.webshare.WebShare ```java // Added public final class WebShare implements Serializable public static > WebShareBinding onClick(T component) public static Signal supportSignal() public static Signal supportSignal(UI ui) ``` ### com.vaadin.flow.component.webshare.WebShareBinding ```java // Added public final class WebShareBinding implements Serializable public void share(ShareContent content) public void share(ShareContent content, SerializableRunnable onShared, SerializableConsumer onError) ``` ### com.vaadin.flow.component.webshare.WebShareSupport ```java // Added public enum WebShareSupport UNKNOWN // initial value before the first bootstrap handshake SUPPORTED UNSUPPORTED ``` --- flow-client/src/main/frontend/Flow.ts | 4 + flow-client/src/main/frontend/WebShare.ts | 24 +++ .../flow/component/internal/UIInternals.java | 30 ++++ .../component/page/ExtendedClientDetails.java | 12 +- .../trigger/internal/ShareAction.java | 160 +++++++++++++++++ .../flow/component/webshare/ShareContent.java | 166 ++++++++++++++++++ .../flow/component/webshare/WebShare.java | 113 ++++++++++++ .../component/webshare/WebShareBinding.java | 99 +++++++++++ .../component/webshare/WebShareSupport.java | 62 +++++++ .../flow/component/webshare/package-info.java | 19 ++ .../trigger/internal/ShareActionTest.java | 107 +++++++++++ .../flow/component/webshare/WebShareTest.java | 159 +++++++++++++++++ 12 files changed, 953 insertions(+), 2 deletions(-) create mode 100644 flow-client/src/main/frontend/WebShare.ts create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ShareAction.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/webshare/ShareContent.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShare.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareBinding.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareSupport.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/webshare/package-info.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ShareActionTest.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/component/webshare/WebShareTest.java diff --git a/flow-client/src/main/frontend/Flow.ts b/flow-client/src/main/frontend/Flow.ts index 2670cb2144e..0e6ad4c8341 100644 --- a/flow-client/src/main/frontend/Flow.ts +++ b/flow-client/src/main/frontend/Flow.ts @@ -11,6 +11,7 @@ import './ElementResize'; import './Geolocation'; import { currentVisibility } from './PageVisibility'; import './WakeLock'; +import { isShareSupported } from './WebShare'; export interface FlowConfig { imports?: () => Promise; @@ -576,6 +577,9 @@ export class Flow { params['v-wla'] = wakeLock.queryAvailability(); } + /* Web Share API support */ + params['v-ws'] = isShareSupported(); + /* Stringify each value (they are parsed on the server side) */ const stringParams: Record = {}; Object.keys(params).forEach((key) => { diff --git a/flow-client/src/main/frontend/WebShare.ts b/flow-client/src/main/frontend/WebShare.ts new file mode 100644 index 00000000000..f01ba9bd7b5 --- /dev/null +++ b/flow-client/src/main/frontend/WebShare.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Returns whether the current browser exposes the Web Share API + * (`navigator.share`). Used by the bootstrap path to seed the server-side + * support signal without waiting for a DOM event. + */ +export function isShareSupported(): boolean { + return typeof navigator.share === 'function'; +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java index fbfe2cf4983..0210ea88f56 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/internal/UIInternals.java @@ -53,6 +53,7 @@ import com.vaadin.flow.component.page.ExtendedClientDetails; import com.vaadin.flow.component.page.Page; import com.vaadin.flow.component.wakelock.WakeLockAvailability; +import com.vaadin.flow.component.webshare.WebShareSupport; import com.vaadin.flow.di.Instantiator; import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementUtil; @@ -250,6 +251,12 @@ public List getParameters() { private final ValueSignal geolocationAvailabilitySignal = new ValueSignal<>( GeolocationAvailability.UNKNOWN); + private final ValueSignal webShareSupportSignal = new ValueSignal<>( + WebShareSupport.UNKNOWN); + + private final Signal webShareSupportReadOnly = webShareSupportSignal + .asReadonly(); + private GeolocationClient geolocationClient; private Registration geolocationClientAvailabilityRegistration; @@ -1510,6 +1517,29 @@ public void setGeolocationAvailability( this.geolocationAvailabilitySignal.set(availability); } + /** + * Returns the read-only reactive signal holding the Web Share API support + * state for this UI. Starts as {@link WebShareSupport#UNKNOWN} before the + * first client bootstrap report, then transitions to the value the browser + * reports. Application code reads it via + * {@link com.vaadin.flow.component.webshare.WebShare#supportSignal()}. + * + * @return the support signal + */ + public Signal getWebShareSupportSignalReadOnly() { + return webShareSupportReadOnly; + } + + /** + * Updates the Web Share support signal. For framework use only. + * + * @param support + * the new support state + */ + public void setWebShareSupport(WebShareSupport support) { + this.webShareSupportSignal.set(support); + } + /** * Returns the geolocation client currently bound to this UI, or * {@code null} if none has been installed yet. Framework-internal: 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 535265e7b6f..71646387aa6 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 @@ -29,6 +29,7 @@ import com.vaadin.flow.component.fullscreen.Fullscreen; import com.vaadin.flow.component.geolocation.GeolocationAvailability; import com.vaadin.flow.component.wakelock.WakeLockAvailability; +import com.vaadin.flow.component.webshare.WebShareSupport; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.server.VaadinSession; @@ -447,8 +448,9 @@ void setColorScheme(ColorScheme.Value colorScheme) { /** * Parses browser details from the given JSON and updates the UI from them: * stores the resulting {@link ExtendedClientDetails} on the UI's internals - * and seeds the page-visibility, geolocation-availability and - * wake-lock-availability signals from the same payload. + * and seeds the page-visibility, geolocation-availability, + * wake-lock-availability and web-share-support signals from the same + * payload. *

* For internal use only. * @@ -520,6 +522,12 @@ public static ExtendedClientDetails updateFromJson(UI ui, JsonNode json) { // unknown value; leave the current availability alone } } + String ws = getStringElseNull.apply("v-ws"); + if (ws != null) { + ui.getInternals().setWebShareSupport( + Boolean.parseBoolean(ws) ? WebShareSupport.SUPPORTED + : WebShareSupport.UNSUPPORTED); + } return details; } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ShareAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ShareAction.java new file mode 100644 index 00000000000..32dbad23175 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ShareAction.java @@ -0,0 +1,160 @@ +/* + * 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.trigger.internal; + +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.SerializableRunnable; + +/** + * Invokes the browser's native share sheet via {@code navigator.share} when the + * bound trigger fires. Supports any combination of {@code title}, {@code text}, + * and {@code url} payload slots; at least one slot must be set — the Web Share + * API rejects a call with an empty payload. + *

+ * The Web Share API requires the call to happen inside a short-lived user + * gesture (click, key press, ...). Bind this action to a trigger that fires + * during such a gesture, typically a {@link ClickTrigger}. The share sheet + * itself acts as the user-facing confirmation; the browser also rejects calls + * made outside a gesture. + *

+ * Outcome handling extends {@link PromiseAction}: use the no-callbacks + * constructor for fire-and-forget, or the overload taking + * {@code onShared}/{@code onError}. {@code onShared} fires after the user + * dismisses the sheet by sharing; {@code onError} fires for both true failures + * (no gesture, permissions policy block) and for the {@code AbortError} the + * browser reports when the user dismisses the sheet without picking a target. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public class ShareAction extends PromiseAction { + + private final Action.@Nullable Input titleInput; + private final Action.@Nullable Input textInput; + private final Action.@Nullable Input urlInput; + + /** + * Creates a fire-and-forget share action. + * + * @param titleInput + * input producing the {@code title} field, or {@code null} to + * omit + * @param textInput + * input producing the {@code text} field, or {@code null} to + * omit + * @param urlInput + * input producing the {@code url} field, or {@code null} to omit + * @throws IllegalArgumentException + * if all three inputs are {@code null} + */ + public ShareAction(Action.@Nullable Input titleInput, + Action.@Nullable Input textInput, + Action.@Nullable Input urlInput) { + super(); + validate(titleInput, textInput, urlInput); + this.titleInput = titleInput; + this.textInput = textInput; + this.urlInput = urlInput; + } + + /** + * Creates a share action whose outcome is reported back to the server. + * + * @param titleInput + * input producing the {@code title} field, or {@code null} to + * omit + * @param textInput + * input producing the {@code text} field, or {@code null} to + * omit + * @param urlInput + * input producing the {@code url} field, or {@code null} to omit + * @param onShared + * invoked on the UI thread after the client reports the share + * resolved, not {@code null} + * @param onError + * invoked on the UI thread with the browser's error after the + * client reports the share rejected — typically + * {@code AbortError} when the user dismissed the sheet, not + * {@code null} + * @throws IllegalArgumentException + * if all three inputs are {@code null} + */ + public ShareAction(Action.@Nullable Input titleInput, + Action.@Nullable Input textInput, + Action.@Nullable Input urlInput, + SerializableRunnable onShared, + SerializableConsumer onError) { + super(Void.class, runnableAsConsumer(onShared), onError); + validate(titleInput, textInput, urlInput); + this.titleInput = titleInput; + this.textInput = textInput; + this.urlInput = urlInput; + } + + private static void validate(Action.@Nullable Input title, + Action.@Nullable Input text, + Action.@Nullable Input url) { + if (title == null && text == null && url == null) { + throw new IllegalArgumentException( + "At least one of titleInput, textInput, urlInput must be non-null"); + } + } + + private static SerializableConsumer<@Nullable Void> runnableAsConsumer( + SerializableRunnable onShared) { + if (onShared == null) { + throw new NullPointerException("onShared must not be null"); + } + return ignored -> onShared.run(); + } + + @Override + protected JsFunction toPromiseJs(Trigger trigger) { + // navigator.share({title:$0(event), ...}) with only the slots that were + // set; each slot's value is produced on the client by invoking the + // input's JsFunction with the trigger event. validate() already ensures + // at least one slot is present, so the object is never empty (the Web + // Share API rejects a call with no payload fields). + StringBuilder expression = new StringBuilder( + "return navigator.share({"); + List args = new ArrayList<>(); + appendSlot(expression, args, "title", titleInput, trigger); + appendSlot(expression, args, "text", textInput, trigger); + appendSlot(expression, args, "url", urlInput, trigger); + expression.append("})"); + return JsFunction.of(expression.toString(), args.toArray()) + .withArguments("event"); + } + + private static void appendSlot(StringBuilder expression, + List args, String key, + Action.@Nullable Input input, Trigger trigger) { + if (input == null) { + return; + } + if (!args.isEmpty()) { + expression.append(','); + } + expression.append(key).append(":$").append(args.size()) + .append("(event)"); + args.add(input.toJs(trigger)); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/webshare/ShareContent.java b/flow-server/src/main/java/com/vaadin/flow/component/webshare/ShareContent.java new file mode 100644 index 00000000000..920708d1ffc --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/webshare/ShareContent.java @@ -0,0 +1,166 @@ +/* + * 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.webshare; + +import java.io.Serializable; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasValue; +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.LiteralInput; +import com.vaadin.flow.component.trigger.internal.PropertyInput; + +/** + * Payload for {@link WebShareBinding#share}. Any combination of {@code title}, + * {@code text}, and {@code url} can be set; the Web Share API requires at least + * one of them to be present. + *

+ * Use the static factory: + * + *

{@code
+ * WebShare.onClick(button).share(ShareContent.create().title("Hello")
+ *         .text("World").url("https://vaadin.com"));
+ * }
+ */ +public final class ShareContent implements Serializable { + + private Action.@Nullable Input titleInput; + private Action.@Nullable Input textInput; + private Action.@Nullable Input urlInput; + + private ShareContent() { + } + + /** + * Creates a new empty content builder. + * + * @return a new builder + */ + public static ShareContent create() { + return new ShareContent(); + } + + /** + * Sets the title of the share payload. + * + * @param literal + * the value, not {@code null} + * @return this builder + */ + public ShareContent title(String literal) { + Objects.requireNonNull(literal, "literal must not be null"); + this.titleInput = new LiteralInput<>(literal); + return this; + } + + /** + * Sets the title of the share payload, taken from the {@code value} + * property of the given component (typically an input field). The value is + * read on the client when the trigger fires. + * + * @param source + * the component whose {@code value} property should be read, not + * {@code null} + * @param + * component type implementing {@code HasValue} + * @return this builder + */ + public > ShareContent title( + C source) { + Objects.requireNonNull(source, "source must not be null"); + this.titleInput = new PropertyInput<>(source, "value", String.class); + return this; + } + + /** + * Sets the free-form text of the share payload. + * + * @param literal + * the value, not {@code null} + * @return this builder + */ + public ShareContent text(String literal) { + Objects.requireNonNull(literal, "literal must not be null"); + this.textInput = new LiteralInput<>(literal); + return this; + } + + /** + * Sets the free-form text of the share payload, taken from the + * {@code value} property of the given component. The value is read on the + * client when the trigger fires. + * + * @param source + * the component whose {@code value} property should be read, not + * {@code null} + * @param + * component type implementing {@code HasValue} + * @return this builder + */ + public > ShareContent text( + C source) { + Objects.requireNonNull(source, "source must not be null"); + this.textInput = new PropertyInput<>(source, "value", String.class); + return this; + } + + /** + * Sets the URL of the share payload. + * + * @param literal + * the value, not {@code null} + * @return this builder + */ + public ShareContent url(String literal) { + Objects.requireNonNull(literal, "literal must not be null"); + this.urlInput = new LiteralInput<>(literal); + return this; + } + + /** + * Sets the URL of the share payload, taken from the {@code value} property + * of the given component. The value is read on the client when the trigger + * fires. + * + * @param source + * the component whose {@code value} property should be read, not + * {@code null} + * @param + * component type implementing {@code HasValue} + * @return this builder + */ + public > ShareContent url( + C source) { + Objects.requireNonNull(source, "source must not be null"); + this.urlInput = new PropertyInput<>(source, "value", String.class); + return this; + } + + Action.@Nullable Input getTitleInput() { + return titleInput; + } + + Action.@Nullable Input getTextInput() { + return textInput; + } + + Action.@Nullable Input getUrlInput() { + return urlInput; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShare.java b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShare.java new file mode 100644 index 00000000000..b1d0e36dd87 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShare.java @@ -0,0 +1,113 @@ +/* + * 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.webshare; + +import java.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.ClickNotifier; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.trigger.internal.ClickTrigger; +import com.vaadin.flow.signals.Signal; + +/** + * Entry point for the browser's + * Web + * Share API ({@code navigator.share}). Two entry points: + *
    + *
  • {@link #onClick(Component)} — bind a share action to a click gesture. The + * Web Share API requires a transient user gesture, so the trigger pattern is + * the only reliable way to invoke it.
  • + *
  • {@link #supportSignal()} — feature detection: read whether the current + * browser exposes {@code navigator.share}, useful for deciding whether to show + * a share affordance at all.
  • + *
+ * + *
{@code
+ * Button share = new Button("Share");
+ * if (WebShare.supportSignal().peek() == WebShareSupport.SUPPORTED) {
+ *     WebShare.onClick(share).share(
+ *             ShareContent.create().title("Vaadin").url("https://vaadin.com"));
+ * }
+ * }
+ * + * The Web Share API requires a fresh user gesture for each call, so actions + * only run during the DOM event that fires the underlying trigger. + */ +public final class WebShare implements Serializable { + + private WebShare() { + // utility class + } + + /** + * Registers the given component as a clickable trigger for a share action — + * the common shape for "Share" buttons. Equivalent to + * {@code new ClickTrigger(component)}, without making callers reach for the + * trigger framework's internal types. + * + * @param component + * the component to listen for clicks on, not {@code null} + * @param + * the component type, must implement {@link ClickNotifier} + * @return a new binding that can chain actions to this trigger + */ + public static > WebShareBinding onClick( + T component) { + Objects.requireNonNull(component, "component must not be null"); + return new WebShareBinding(new ClickTrigger(component)); + } + + /** + * Returns a read-only signal hinting at whether the Web Share API is + * available in the current browser for the current UI. The value is seeded + * from the bootstrap handshake, so user code observes a real value before + * any view code runs. + *

+ * Web Share support is established at page load and does not change during + * the session, so the signal effectively transitions {@code UNKNOWN} → + * {@code SUPPORTED}/{@code UNSUPPORTED} once and then remains stable. + *

+ * Use this to decide whether to show a share affordance at all — calling a + * share action when the value is {@link WebShareSupport#UNSUPPORTED} + * silently rejects in the browser. + * + * @return the read-only support signal + * @throws IllegalStateException + * if there is no current UI + */ + public static Signal supportSignal() { + return supportSignal(UI.getCurrentOrThrow()); + } + + /** + * Returns a read-only signal hinting at whether the Web Share API is + * available in the current browser for the given UI. Same semantics as + * {@link #supportSignal()}; use this overload from background threads or + * anywhere {@link UI#getCurrent()} is unreliable. + * + * @param ui + * the UI to read the hint from, not {@code null} + * @return the read-only support signal + * @throws NullPointerException + * if {@code ui} is {@code null} + */ + public static Signal supportSignal(UI ui) { + Objects.requireNonNull(ui, "ui must not be null"); + return ui.getInternals().getWebShareSupportSignalReadOnly(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareBinding.java b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareBinding.java new file mode 100644 index 00000000000..e52bffe3e2b --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareBinding.java @@ -0,0 +1,99 @@ +/* + * 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.webshare; + +import java.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.trigger.internal.PromiseAction.Error; +import com.vaadin.flow.component.trigger.internal.ShareAction; +import com.vaadin.flow.component.trigger.internal.Trigger; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.SerializableRunnable; + +/** + * Fluent surface returned from {@link WebShare#onClick}. Each {@code share} + * action attaches one {@link ShareAction} to the underlying {@link Trigger}. + *

+ * Actions come in two flavours: fire-and-forget (one argument) and observed + * (with {@code onShared}/{@code onError} callbacks). {@code onShared} fires + * after the user picks a share target; {@code onError} fires when the user + * dismisses the sheet (the browser reports {@code AbortError}) or when the call + * is rejected for other reasons (no gesture, permissions policy block, + * unsupported browser). Both consumers are required in the observed form — pass + * {@code () -> {}} or {@code err -> {}} to opt out of one. + * + *

{@code
+ * Button share = new Button("Share");
+ * WebShare.onClick(share).share(
+ *         ShareContent.create().title("Hello").url("https://vaadin.com"));
+ *
+ * WebShare.onClick(share).share(
+ *         ShareContent.create().url("https://vaadin.com"),
+ *         () -> Notification.show("Shared!"),
+ *         err -> Notification.show("Cancelled: " + err.name()));
+ * }
+ */ +public final class WebShareBinding implements Serializable { + + private final Trigger trigger; + + WebShareBinding(Trigger trigger) { + this.trigger = Objects.requireNonNull(trigger); + } + + /** + * Invokes the browser's native share sheet with the given content when the + * underlying trigger fires. + * + * @param content + * the content, not {@code null}; must have at least one slot set + * @throws IllegalArgumentException + * if {@code content} has no slots set + */ + public void share(ShareContent content) { + Objects.requireNonNull(content, "content must not be null"); + bind(new ShareAction(content.getTitleInput(), content.getTextInput(), + content.getUrlInput())); + } + + /** + * Like {@link #share(ShareContent)} but reports the outcome back to the + * server. + * + * @param content + * the content, not {@code null}; must have at least one slot set + * @param onShared + * UI-thread callback invoked after the user picked a share + * target, not {@code null} + * @param onError + * UI-thread callback receiving the browser's error (including + * {@code AbortError} when the user dismissed the sheet), not + * {@code null} + * @throws IllegalArgumentException + * if {@code content} has no slots set + */ + public void share(ShareContent content, SerializableRunnable onShared, + SerializableConsumer onError) { + Objects.requireNonNull(content, "content must not be null"); + bind(new ShareAction(content.getTitleInput(), content.getTextInput(), + content.getUrlInput(), onShared, onError)); + } + + private void bind(ShareAction action) { + trigger.triggers(action); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareSupport.java b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareSupport.java new file mode 100644 index 00000000000..3822a9cc38c --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/webshare/WebShareSupport.java @@ -0,0 +1,62 @@ +/* + * 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.webshare; + +/** + * Whether the browser exposes the Web Share API ({@code navigator.share}). + *

+ * Held by {@link WebShare#supportSignal()}. Reading the value does not show any + * browser dialog — it reports whether a subsequent {@link WebShareBinding#share + * share} action would be able to invoke the native share sheet, or whether the + * call would silently no-op because the API is missing in the current browser + * context. + *

+ * Typical usage: + *

    + *
  • {@link #SUPPORTED} — show a "Share" button that triggers a share + * action.
  • + *
  • {@link #UNSUPPORTED} — fall back to a copy-link or social-network + * affordance; the native sheet is unavailable in this browser.
  • + *
  • {@link #UNKNOWN} — only seen in the brief window before the first + * bootstrap handshake completes; treat the same as {@link #UNSUPPORTED} until a + * real value arrives.
  • + *
+ */ +public enum WebShareSupport { + + /** + * No value has been reported by the browser yet. Used only as the initial + * value of the signal before the first client handshake delivers the real + * one. In normal request handling the signal is seeded before any user code + * (UI initialization, {@code UIInitListener}, component attach) 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 exposes {@code navigator.share}; share actions bound to a + * user gesture will invoke the native share sheet. + */ + SUPPORTED, + + /** + * The browser does not expose {@code navigator.share}; share actions + * silently reject. Most desktop Firefox builds and older browsers fall in + * this bucket. + */ + UNSUPPORTED +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/webshare/package-info.java b/flow-server/src/main/java/com/vaadin/flow/component/webshare/package-info.java new file mode 100644 index 00000000000..49ec49f3942 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/webshare/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +@NullMarked +package com.vaadin.flow.component.webshare; + +import org.jspecify.annotations.NullMarked; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ShareActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ShareActionTest.java new file mode 100644 index 00000000000..ced4f71b175 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ShareActionTest.java @@ -0,0 +1,107 @@ +/* + * 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.trigger.internal; + +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.tests.util.MockUI; + +import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.actionOf; +import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.singleInstallFn; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ShareActionTest { + + @Test + void fireAndForget_allSlots_emitsNavigatorShareWithEachInputFunction() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + ui.getElement().appendChild(button.getElement()); + + new DomEventTrigger(button, "click").triggers(new ShareAction( + new LiteralInput<>("Hi"), new LiteralInput<>("World"), + new LiteralInput<>("https://vaadin.com"))); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + // Each slot value is produced on the client by invoking its input + // JsFunction with the event; the literal values are captured, not + // stringified into the body. + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals( + "return navigator.share({title:$0(event),text:$1(event),url:$2(event)})", + action.getBody()); + assertEquals("Hi", ((JsFunction) action.getCaptures().get(0)) + .getCaptures().get(0)); + assertEquals("World", ((JsFunction) action.getCaptures().get(1)) + .getCaptures().get(0)); + assertEquals("https://vaadin.com", + ((JsFunction) action.getCaptures().get(2)).getCaptures() + .get(0)); + } + + @Test + void fireAndForget_onlyUrl_emitsObjectWithSingleField() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + ui.getElement().appendChild(button.getElement()); + + new DomEventTrigger(button, "click").triggers(new ShareAction(null, + null, new LiteralInput<>("https://vaadin.com"))); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals("return navigator.share({url:$0(event)})", + action.getBody()); + assertEquals("https://vaadin.com", + ((JsFunction) action.getCaptures().get(0)).getCaptures() + .get(0)); + } + + @Test + void withCallbacks_wrapsInnerNavigatorSharePromiseWithObserver() { + UI ui = new MockUI(); + TagComponent button = new TagComponent("button"); + ui.getElement().appendChild(button.getElement()); + + new DomEventTrigger(button, "click").triggers( + new ShareAction(new LiteralInput<>("Hi"), null, null, () -> { + }, err -> { + })); + + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + // With outcome handling the action wraps the inner promise function + // with OBSERVE_PROMISE + the return channel; the inner $1 still calls + // navigator.share. + JsFunction action = actionOf(singleInstallFn(ui)); + assertEquals("$0($1(event), $2)", action.getBody()); + + JsFunction inner = (JsFunction) action.getCaptures().get(1); + assertEquals("return navigator.share({title:$0(event)})", + inner.getBody()); + } + + @Test + void allInputsNull_throws() { + assertThrows(IllegalArgumentException.class, + () -> new ShareAction(null, null, null)); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/webshare/WebShareTest.java b/flow-server/src/test/java/com/vaadin/flow/component/webshare/WebShareTest.java new file mode 100644 index 00000000000..88df914f8ab --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/webshare/WebShareTest.java @@ -0,0 +1,159 @@ +/* + * 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.webshare; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.AbstractField; +import com.vaadin.flow.component.ClickNotifier; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation; +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.tests.util.MockUI; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WebShareTest { + + @Tag("test-button") + static final class TestButton extends Component + implements ClickNotifier { + } + + @Tag("test-field") + static final class TestField extends AbstractField { + TestField() { + super(""); + } + + @Override + protected void setPresentationValue(String newPresentationValue) { + // not exercised in these tests + } + } + + @Test + void onClick_installsClickTrigger() { + UI ui = new MockUI(); + TestButton button = new TestButton(); + ui.getElement().appendChild(button.getElement()); + + WebShare.onClick(button).share(ShareContent.create().title("Hi")); + + // The event name is a capture of the install function, not inlined into + // the body. + JsFunction install = installFn(ui); + assertTrue(install.getCaptures().contains("click"), + "click trigger install captures: " + install.getCaptures()); + } + + @Test + void share_literalSlots_emitsNavigatorShareWithAllFields() { + UI ui = new MockUI(); + TestButton button = new TestButton(); + ui.getElement().appendChild(button.getElement()); + + WebShare.onClick(button).share(ShareContent.create().title("Hi") + .text("World").url("https://vaadin.com")); + + // Each slot value is produced by invoking its input JsFunction with the + // event; the literals are captured inside those nested functions. + JsFunction handler = handlerFn(ui); + assertEquals( + "return navigator.share({title:$0(event),text:$1(event),url:$2(event)})", + handler.getBody()); + assertEquals("Hi", ((JsFunction) handler.getCaptures().get(0)) + .getCaptures().get(0)); + assertEquals("World", ((JsFunction) handler.getCaptures().get(1)) + .getCaptures().get(0)); + assertEquals("https://vaadin.com", + ((JsFunction) handler.getCaptures().get(2)).getCaptures() + .get(0)); + } + + @Test + void share_titleFromHasValue_emitsPropertyInputForValue() { + UI ui = new MockUI(); + TestButton button = new TestButton(); + TestField field = new TestField(); + ui.getElement().appendChild(button.getElement(), field.getElement()); + + WebShare.onClick(button).share(ShareContent.create().title(field)); + + // The title slot reads the field's "value" property on the client; the + // PropertyInput renders as its own JsFunction (return $0[$1]) with the + // property name captured at $1. + JsFunction handler = handlerFn(ui); + assertEquals("return navigator.share({title:$0(event)})", + handler.getBody()); + JsFunction titleInput = (JsFunction) handler.getCaptures().get(0); + assertEquals("return $0[$1]", titleInput.getBody()); + assertEquals("value", titleInput.getCaptures().get(1)); + } + + @Test + void share_emptyContent_throws() { + TestButton button = new TestButton(); + assertThrows(IllegalArgumentException.class, + () -> WebShare.onClick(button).share(ShareContent.create())); + } + + @Test + void supportSignal_initiallyUnknown() { + UI ui = new MockUI(); + UI.setCurrent(ui); + try { + assertEquals(WebShareSupport.UNKNOWN, + WebShare.supportSignal().peek()); + } finally { + UI.setCurrent(null); + } + } + + @Test + void supportSignal_reflectsInternalsUpdate() { + UI ui = new MockUI(); + ui.getInternals().setWebShareSupport(WebShareSupport.SUPPORTED); + assertEquals(WebShareSupport.SUPPORTED, + WebShare.supportSignal(ui).peek()); + } + + private static JsFunction handlerFn(UI ui) { + Object handler = installFn(ui).getCaptures().get(0); + assertTrue(handler instanceof JsFunction, + "install $0 is the handler JsFunction"); + return (JsFunction) handler; + } + + private static JsFunction installFn(UI ui) { + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + List pending = ui.getInternals() + .dumpPendingJavaScriptInvocations(); + assertEquals(1, pending.size(), + "Expected exactly one pending JS invocation"); + JavaScriptInvocation invocation = pending.get(0).getInvocation(); + Object o = invocation.getParameters().get(2); + assertTrue(o instanceof JsFunction, "Expected $2 to be a JsFunction"); + return (JsFunction) o; + } +}