From 86f0f12698063e8c4ad8c675a770375025df5837 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:58:33 +0000 Subject: [PATCH] feat: let trigger actions be fired from client-rendered templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger bindings so far name the component that fires them, which means one server-side component, one trigger and one addJsInitializer registration per affordance. A copy button in every row of a grid therefore costs a binding per rendered row, rebuilt every time a row scrolls out of the buffer and back, while a LitRenderer — the tool that exists to avoid per-row components — cannot help: its withFunction callbacks round-trip to the server, and the browser's transient activation is gone by the time the server responds, so navigator.clipboard.write and friends reject. Add a seam for actions that are not bound to a component: - ClientAction, an unbound action handle produced by the feature facades (Clipboard.write() to begin with), which whatever renders the affordance binds once and fires per rendered element. - ClientActionSink, implemented by the renderer that accepts the action; it receives the rendered JsFunction and gets it invoked on the client. - ClientValue, a value the action reads on the client when it runs rather than one captured on the server when it is bound. ClientValue.itemProperty reads it from the item the action fired for, which is what lets a single binding serve a whole column. - SinkTrigger, a trigger that installs no listener of its own and hands its rendered action to the sink instead. Rendered handlers now take (event, context) rather than (event): the context describes what the trigger fired for — {item, index, key} for a renderer — and actions forward it to their inputs. A handler that declares only event keeps working, since the extra argument is simply ignored on the client, so the convention is adoptable one action at a time. An input that reads the context rejects a trigger that supplies none, so binding an item-scoped value to a plain click trigger fails on the server instead of evaluating to undefined in the browser. --- .../flow/component/clipboard/Clipboard.java | 24 +++ .../component/clipboard/ClipboardWrite.java | 88 ++++++++++ .../flow/component/trigger/ClientAction.java | 70 ++++++++ .../component/trigger/ClientActionSink.java | 49 ++++++ .../flow/component/trigger/ClientValue.java | 87 ++++++++++ .../component/trigger/internal/Action.java | 22 ++- .../trigger/internal/CallbackAction.java | 5 +- .../trigger/internal/ClientActions.java | 60 +++++++ .../trigger/internal/ContextInput.java | 76 +++++++++ .../trigger/internal/DownloadAction.java | 14 +- .../trigger/internal/OpenInNewTabAction.java | 8 +- .../trigger/internal/PromiseAction.java | 12 +- .../trigger/internal/SetPropertyAction.java | 5 +- .../trigger/internal/ShareAction.java | 7 +- .../trigger/internal/SinkTrigger.java | 69 ++++++++ .../component/trigger/internal/Trigger.java | 32 ++++ .../internal/WriteToClipboardAction.java | 4 +- .../flow/component/trigger/package-info.java | 27 ++++ .../component/fullscreen/FullscreenTest.java | 4 +- .../component/trigger/ClientActionTest.java | 153 ++++++++++++++++++ .../trigger/CrossPackageExtensionTest.java | 7 +- .../trigger/internal/CallbackActionTest.java | 2 +- .../trigger/internal/DomEventTriggerTest.java | 7 +- .../trigger/internal/DownloadActionTest.java | 5 +- .../internal/OpenInNewTabActionTest.java | 2 +- .../trigger/internal/PromiseActionTest.java | 2 +- .../internal/ReadFromClipboardActionTest.java | 2 +- .../internal/RequestFullscreenActionTest.java | 4 +- .../trigger/internal/ShareActionTest.java | 8 +- .../internal/WriteToClipboardActionTest.java | 6 +- .../flow/component/webshare/WebShareTest.java | 4 +- 31 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardWrite.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientAction.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientActionSink.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientValue.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ClientActions.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ContextInput.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SinkTrigger.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/component/trigger/ClientActionTest.java diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java index 2936cea9505..debf1f763f9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/Clipboard.java @@ -165,6 +165,30 @@ public static > ClipboardBinding onClick( return new ClipboardBinding(new ClickTrigger(component)); } + /** + * Starts a clipboard write that is not bound to any component — the entry + * point for copy affordances rendered on the client, where there is no + * server-side component to bind to. Chain the payload onto the returned + * {@link ClipboardWrite} and hand the resulting + * {@link com.vaadin.flow.component.trigger.ClientAction} to whatever + * renders the affordance: + * + *
{@code
+     * LitRenderer. of(
+     *         "${item.email}")
+     *         .withProperty("email", Customer::email).withClientAction("copy",
+     *                 Clipboard.write().text(ClientValue.itemProperty("email")));
+     * }
+ * + * The action still runs inside the browser.s own event handler, so the user + * gesture is valid, exactly as with {@link #onClick(Component)}. + * + * @return a fluent surface for declaring what to copy + */ + public static ClipboardWrite write() { + return new ClipboardWrite(); + } + /** * Registers a listener for browser {@code paste} events on the given * component. The listener is invoked on the UI thread once per paste diff --git a/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardWrite.java b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardWrite.java new file mode 100644 index 00000000000..dc22590b9f7 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/clipboard/ClipboardWrite.java @@ -0,0 +1,88 @@ +/* + * 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.clipboard; + +import java.io.Serializable; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.trigger.ClientAction; +import com.vaadin.flow.component.trigger.ClientValue; +import com.vaadin.flow.component.trigger.internal.ClientActions; +import com.vaadin.flow.component.trigger.internal.PromiseAction.Error; +import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction; +import com.vaadin.flow.function.SerializableConsumer; + +/** + * Fluent surface returned from {@link Clipboard#write()}, used to declare what + * a clipboard write copies when something on the client fires it. + *

+ * Unlike {@link Clipboard#onClick(com.vaadin.flow.component.Component)}, the + * write declared here is not bound to a component: it produces a + * {@link ClientAction} that whatever renders the affordance fires — typically a + * renderer that draws the same affordance for every item it renders. + * + *

{@code
+ * LitRenderer. of(
+ *         "${item.email}")
+ *         .withProperty("email", Customer::email).withClientAction("copy",
+ *                 Clipboard.write().text(ClientValue.itemProperty("email")));
+ * }
+ */ +public final class ClipboardWrite implements Serializable { + + ClipboardWrite() { + // Created by Clipboard.write() + } + + /** + * Copies the given value to the clipboard as {@code text/plain}. The value + * is read on the client at the moment the action fires, which is what lets + * one action serve every item a renderer draws. + * + * @param value + * the value to copy, not {@code null} + * @return an unbound action, to be handed to whatever fires it + */ + public ClientAction text(ClientValue value) { + Objects.requireNonNull(value, "value must not be null"); + return ClientActions + .of(new WriteToClipboardAction(value.getInput(), null)); + } + + /** + * Like {@link #text(ClientValue)} but reports the outcome back to the + * server. + * + * @param value + * the value to copy, not {@code null} + * @param onCopied + * UI-thread callback receiving the copied string, not + * {@code null} + * @param onError + * UI-thread callback receiving the browser's error, not + * {@code null} + * @return an unbound action, to be handed to whatever fires it + */ + public ClientAction text(ClientValue value, + SerializableConsumer<@Nullable String> onCopied, + SerializableConsumer onError) { + Objects.requireNonNull(value, "value must not be null"); + return ClientActions.of(new WriteToClipboardAction(value.getInput(), + null, onCopied, onError)); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientAction.java new file mode 100644 index 00000000000..1eb6d66a44e --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientAction.java @@ -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. + */ +package com.vaadin.flow.component.trigger; + +import java.io.Serializable; + +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +/** + * A browser-side action — copy to the clipboard, share, enter fullscreen, start + * a download — that is not yet bound to anything that fires it. + *

+ * Ordinary trigger bindings name the component that fires them + * ({@code Clipboard.onClick(button)}), which requires a server-side component + * per binding. A {@code ClientAction} instead describes only what + * should happen, so it can be handed to something that renders its own elements + * on the client — a {@code LitRenderer} template, for example — and fired from + * there once per rendered element while still costing one binding: + * + *

{@code
+ * grid.addColumn(LitRenderer. of(
+ *         "${item.email}")
+ *         .withProperty("email", Customer::email).withClientAction("copy",
+ *                 Clipboard.write().text(ClientValue.itemProperty("email"))));
+ * }
+ * + * The action runs inside the browser's own event handler, so the user gesture + * is still valid — the whole reason clipboard, share and fullscreen calls + * cannot be made from a server-side listener. + *

+ * Instances are created by the feature facades ({@code Clipboard.write()}, + * {@code WebShare.share(…)}, …), not by application code. + * + * @see ClientValue + */ +public interface ClientAction extends Serializable { + + /** + * Binds this action to {@code host} and hands the rendered client-side + * function to {@code sink}, which decides when it runs. + *

+ * Called by the component or renderer that accepts the action, once per + * place it is rendered into. The returned {@link Registration} detaches the + * binding. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @param host + * the element whose lifecycle the binding belongs to, not + * {@code null} + * @param sink + * receives the rendered action function, not {@code null} + * @return a registration that detaches the binding, never {@code null} + */ + Registration bindTo(Element host, ClientActionSink sink); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientActionSink.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientActionSink.java new file mode 100644 index 00000000000..0a76b52ca47 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientActionSink.java @@ -0,0 +1,49 @@ +/* + * 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; + +import java.io.Serializable; + +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.flow.shared.Registration; + +/** + * Receives the client-side function a {@link ClientAction} renders to, and is + * responsible for getting it invoked in the browser. + *

+ * Implemented by whatever accepts client actions — typically a renderer that + * passes the function into its own client-side template so a {@code @click} + * binding can call it. The function takes {@code (event, context)}: the client + * event that fired it, and an object describing what it fired for. A row + * renderer supplies {@code {item, index, key}} for the row the event came from; + * that context is what {@link ClientValue#itemProperty(String)} reads. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +@FunctionalInterface +public interface ClientActionSink extends Serializable { + + /** + * Takes the rendered action function and arranges for it to be called on + * the client. + * + * @param action + * the action function, to be invoked as + * {@code action(event, context)}, not {@code null} + * @return a registration that undoes the installation, never {@code null} + */ + Registration install(JsFunction action); +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientValue.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientValue.java new file mode 100644 index 00000000000..6a560454c95 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/ClientValue.java @@ -0,0 +1,87 @@ +/* + * 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; + +import java.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.ContextInput; +import com.vaadin.flow.component.trigger.internal.LiteralInput; + +/** + * A value a {@link ClientAction} reads on the client when it runs, rather than + * one captured on the server when it is bound. + *

+ * This is what makes a single binding usable for many rendered elements: the + * copy button in every grid row is one action, and + * {@link #itemProperty(String)} resolves the row it was clicked in. + * + *

{@code
+ * Clipboard.write().text(ClientValue.itemProperty("email"));
+ * }
+ * + * @param + * the type of the value produced + */ +public final class ClientValue implements Serializable { + + private final Action.Input input; + + private ClientValue(Action.Input input) { + this.input = input; + } + + /** + * A property of the item the action fired for, read from the renderer's + * client-side item data. The property must be one the renderer sends to the + * client — for a {@code LitRenderer}, one declared with + * {@code withProperty}. + * + * @param propertyName + * the item property to read, not {@code null} + * @return a value resolving to that property of the item the action fired + * for + */ + public static ClientValue itemProperty(String propertyName) { + Objects.requireNonNull(propertyName, "propertyName must not be null"); + return new ClientValue<>( + new ContextInput<>("item", propertyName, String.class)); + } + + /** + * A fixed value, the same for every element the action is rendered into. + * + * @param value + * the value, not {@code null} + * @return a value resolving to {@code value} + */ + public static ClientValue of(String value) { + Objects.requireNonNull(value, "value must not be null"); + return new ClientValue<>(new LiteralInput<>(value)); + } + + /** + * The input backing this value. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @return the input, never {@code null} + */ + public Action.Input getInput() { + return input; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Action.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Action.java index 76096a91952..789fcae6433 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Action.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Action.java @@ -57,13 +57,19 @@ public abstract class Action implements Serializable { /** * Builds the {@link JsFunction} that runs this action when the surrounding - * trigger fires. The returned function takes one runtime argument named - * {@code event} (declared by the framework when it composes the trigger - * handler); subclasses do not declare argument names themselves. + * trigger fires. The returned function takes two runtime arguments: {@code + * event}, the client event that fired the trigger, and {@code context}, the + * trigger context — an object describing what the trigger fired for, or + * {@code undefined} for triggers that have no such notion. A + * {@link SinkTrigger} rendered into a client-side row renderer, for + * example, supplies {@code {item, index, key}} for the row the event came + * from, which is what lets one action serve a whole column. *

* The body is one statement. To embed a value produced on the client, * capture an {@link Input}'s {@link Input#toJs(Trigger) JsFunction} as a - * capture and invoke it inside the body as {@code $N(event)}. + * capture and invoke it inside the body as {@code $N(event, context)} — + * always forwarding both arguments, so inputs that read the context work in + * every action. * * @param trigger * the surrounding trigger this render is for, not {@code null} @@ -121,10 +127,10 @@ public abstract static class Input implements Serializable { /** * Builds the {@link JsFunction} that yields this input's value when - * called. The function may take {@code event} as a runtime argument - * (declared by the subclass via - * {@link JsFunction#withArguments(String...)}); inputs that don't need - * {@code event} simply omit the declaration and ignore the argument the + * called. The function may take {@code event} and {@code context} as + * runtime arguments (declared by the subclass via + * {@link JsFunction#withArguments(String...)}); inputs that need + * neither simply omit the declaration and ignore the arguments the * caller passes. * *

diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/CallbackAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/CallbackAction.java index 11f8fbe3e05..6dae83ab3be 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/CallbackAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/CallbackAction.java @@ -141,8 +141,9 @@ protected final JsFunction toJs(Trigger trigger) { // $0 = the return channel; $1 = the source input's JsFunction. // Invoking the source with `event` produces its value, which is // forwarded straight into the channel call. - return JsFunction.of("$0($1(event));", channel, source.toJs(trigger)) - .withArguments("event"); + return JsFunction + .of("$0($1(event, context));", channel, source.toJs(trigger)) + .withArguments("event", "context"); } private ReturnChannelRegistration channelFor(StateNode hostNode) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ClientActions.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ClientActions.java new file mode 100644 index 00000000000..4f170fced13 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ClientActions.java @@ -0,0 +1,60 @@ +/* + * 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.io.Serializable; +import java.util.Objects; + +import com.vaadin.flow.component.trigger.ClientAction; +import com.vaadin.flow.component.trigger.ClientActionSink; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +/** + * Bridges the public {@link ClientAction} handle to the internal {@link Action} + * it carries, so feature facades (clipboard, fullscreen, share, …) can hand an + * unbound action to whatever renders it without exposing the trigger internals. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public final class ClientActions implements Serializable { + + private ClientActions() { + // utility class + } + + /** + * Wraps an internal action as a public {@link ClientAction} handle. Binding + * the handle creates a {@link SinkTrigger} on the host element and wires + * the action to it. + * + * @param action + * the action to run when whatever renders the handle fires it, + * not {@code null} + * @return an unbound client action handle + */ + public static ClientAction of(Action action) { + Objects.requireNonNull(action, "action must not be null"); + return new ClientAction() { + @Override + public Registration bindTo(Element host, ClientActionSink sink) { + SinkTrigger trigger = new SinkTrigger(host, sink); + trigger.triggers(action); + return trigger::remove; + } + }; + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ContextInput.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ContextInput.java new file mode 100644 index 00000000000..75172e50afa --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/ContextInput.java @@ -0,0 +1,76 @@ +/* + * 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.Objects; + +import com.vaadin.flow.dom.JsFunction; + +/** + * Reads a value out of the trigger context — the object a trigger supplies to + * describe what it fired for. A {@link SinkTrigger} rendered into a row + * renderer supplies {@code {item, index, key}} for the row the event came from, + * so {@code new ContextInput<>("item", "email", String.class)} yields that + * row's {@code email} property at fire time. + *

+ * This is what lets one action serve a whole column: the value is not captured + * on the server per row, it is read from the row's own client-side data when + * the action runs. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @param + * the runtime type of the value produced + */ +public class ContextInput extends Action.Input { + + private final String contextProperty; + private final String valueProperty; + + /** + * Creates an input reading {@code context[contextProperty][valueProperty]} + * at fire time. + * + * @param contextProperty + * the context member to read from (e.g. {@code "item"}), not + * {@code null} + * @param valueProperty + * the property to read off that member, not {@code null} + * @param valueType + * runtime type of the produced value, not {@code null} + */ + public ContextInput(String contextProperty, String valueProperty, + Class valueType) { + this.contextProperty = Objects.requireNonNull(contextProperty); + this.valueProperty = Objects.requireNonNull(valueProperty); + Objects.requireNonNull(valueType); + } + + @Override + public JsFunction toJs(Trigger trigger) { + if (!trigger.suppliesContext()) { + throw new IllegalArgumentException("Input reads the trigger " + + "context, but a " + trigger.getClass().getSimpleName() + + " supplies none. Use a value source that does not depend " + + "on the context, or bind the action to something that " + + "renders repeating elements."); + } + // Both names are captures, so nothing is concatenated into the body. + return JsFunction + .of("return context[$0][$1]", contextProperty, valueProperty) + .withArguments("event", "context"); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/DownloadAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/DownloadAction.java index 55db36d72b0..a15ab26db2d 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/DownloadAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/DownloadAction.java @@ -167,13 +167,15 @@ protected JsFunction toJs(Trigger trigger) { // emitted JS minimal. Both URL and filename are JsFunctions invoked // with event so any handler-scoped inputs read live state. if (filenameInput == null) { - return JsFunction.of("window.Vaadin.Flow.download.start($0(event))", - urlInput.toJs(trigger)).withArguments("event"); + return JsFunction + .of("window.Vaadin.Flow.download.start($0(event, context))", + urlInput.toJs(trigger)) + .withArguments("event", "context"); } - return JsFunction - .of("window.Vaadin.Flow.download.start($0(event), $1(event))", - urlInput.toJs(trigger), filenameInput.toJs(trigger)) - .withArguments("event"); + return JsFunction.of( + "window.Vaadin.Flow.download.start($0(event, context), $1(event, context))", + urlInput.toJs(trigger), filenameInput.toJs(trigger)) + .withArguments("event", "context"); } private static LiteralInput literal(String value, String name) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabAction.java index a18479ef7fd..646d1ecbcc2 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabAction.java @@ -196,10 +196,12 @@ protected JsFunction toJs(Trigger trigger) { // resolved URL is a javascript: URL; see its Javadoc for the regex. // $0 = URL input's JsFunction; $1 = features input's JsFunction — // both invoked with the firing event so handler-scoped inputs work. - return JsFunction.of("((u) => " + JAVASCRIPT_SCHEME_JS_GUARD - + " || window.open(u, \"_blank\", $1(event)))" + "($0(event))", + return JsFunction.of( + "((u) => " + JAVASCRIPT_SCHEME_JS_GUARD + + " || window.open(u, \"_blank\", $1(event, context)))" + + "($0(event, context))", urlInput.toJs(trigger), featuresInput.toJs(trigger)) - .withArguments("event"); + .withArguments("event", "context"); } private static LiteralInput urlLiteral(String url) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/PromiseAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/PromiseAction.java index aac29adc908..b66ea124803 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/PromiseAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/PromiseAction.java @@ -143,8 +143,8 @@ protected PromiseAction(Class payloadType, * *

{@code
      * return JsFunction.of(
-     *         "return ((v) => navigator.clipboard.writeText(v).then(() => v))($0(event))",
-     *         textInput.toJs(trigger)).withArguments("event");
+     *         "return ((v) => navigator.clipboard.writeText(v).then(() => v))($0(event, context))",
+     *         textInput.toJs(trigger)).withArguments("event", "context");
      * }
* * @param trigger @@ -172,12 +172,12 @@ protected final JsFunction toJs(Trigger trigger) { } ReturnChannelRegistration channel = channelFor( trigger.getHost().getNode()); - // $0(observer)($1(event)(promise), $2(channel)) — invoke the inner + // $0(observer)($1(event, context)(promise), $2(channel)) — invoke the + // inner // function to get a Promise, then hand it plus the return channel to // the shared observer JsFunction which subscribes to .then/.catch. - return JsFunction - .of("$0($1(event), $2)", OBSERVE_PROMISE, inner, channel) - .withArguments("event"); + return JsFunction.of("$0($1(event, context), $2)", OBSERVE_PROMISE, + inner, channel).withArguments("event", "context"); } private ReturnChannelRegistration channelFor(StateNode hostNode) { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SetPropertyAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SetPropertyAction.java index efb9af6ea74..b8ca055abb4 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SetPropertyAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SetPropertyAction.java @@ -120,7 +120,8 @@ protected JsFunction toJs(Trigger trigger) { // $0 = target element (captured), $1 = property name (string // capture, Jackson-quoted on the client), $2 = source JsFunction // (invoked with event so handler-scoped inputs work). - return JsFunction.of("$0[$1] = $2(event)", target, propertyName, - source.toJs(trigger)).withArguments("event"); + return JsFunction.of("$0[$1] = $2(event, context)", target, + propertyName, source.toJs(trigger)) + .withArguments("event", "context"); } } 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 index e59e41b9fbb..bbbcb9c1fa6 100644 --- 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 @@ -130,7 +130,8 @@ private static void validate(Action.@Nullable Input title, @Override protected JsFunction toPromiseJs(Trigger trigger) { - // navigator.share({title:$0(event), ...}) with only the slots that were + // navigator.share({title:$0(event, context), ...}) 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 @@ -143,7 +144,7 @@ protected JsFunction toPromiseJs(Trigger trigger) { appendSlot(expression, args, "url", urlInput, trigger); expression.append("})"); return JsFunction.of(expression.toString(), args.toArray()) - .withArguments("event"); + .withArguments("event", "context"); } private static void appendSlot(StringBuilder expression, @@ -156,7 +157,7 @@ private static void appendSlot(StringBuilder expression, expression.append(','); } expression.append(key).append(":$").append(args.size()) - .append("(event)"); + .append("(event, context)"); args.add(input.toJs(trigger)); } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SinkTrigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SinkTrigger.java new file mode 100644 index 00000000000..8cba8788285 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/SinkTrigger.java @@ -0,0 +1,69 @@ +/* + * 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.Objects; + +import com.vaadin.flow.component.trigger.ClientActionSink; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.flow.shared.Registration; + +/** + * A trigger that installs no client-side listener of its own: it hands the + * rendered action function to a {@link ClientActionSink} and lets the sink + * decide when it runs. + *

+ * Used where the thing that fires is rendered on the client and has no + * server-side component to listen on — a {@code LitRenderer} template binding + * {@code @click=${copy}}, for example. The sink passes the function into the + * renderer, the template calls it from its own event binding, and the action + * therefore runs inside the browser's event handler with the user gesture still + * valid, exactly as it does for a {@link DomEventTrigger}. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public class SinkTrigger extends Trigger { + + private final ClientActionSink sink; + + /** + * Creates a trigger that hands its rendered actions to {@code sink}. + * + * @param host + * the element whose lifecycle the trigger belongs to (the + * renderer's container), not {@code null} + * @param sink + * receives the rendered action function, not {@code null} + */ + public SinkTrigger(Element host, ClientActionSink sink) { + super(host); + this.sink = Objects.requireNonNull(sink, "sink must not be null"); + } + + @Override + public boolean suppliesContext() { + // The renderer knows which item each rendered element belongs to and + // passes it to the action as the context argument. + return true; + } + + @Override + protected Registration install(JsFunction action) { + return Objects.requireNonNull(sink.install(action), + "ClientActionSink.install must return a Registration"); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Trigger.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Trigger.java index 66ca78a25c5..bfdf6f9dcac 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Trigger.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/Trigger.java @@ -87,6 +87,21 @@ protected Trigger(Component host) { verifyArmedBeforeClientResponse(); } + /** + * Creates a new trigger bound to the given element. + *

+ * Used by triggers whose host has no component of its own — for example a + * renderer whose container element is all the server side has of the rows + * it renders. + * + * @param host + * the element the trigger fires on, not {@code null} + */ + protected Trigger(Element host) { + this.host = Objects.requireNonNull(host); + verifyArmedBeforeClientResponse(); + } + /** * Schedules a check, run once the host is attached and just before the * client response is built, that fails if no action was committed to this @@ -124,6 +139,23 @@ private void verifyArmedBeforeClientResponse() { })); } + /** + * Whether this trigger supplies a context object to the actions it fires, + * describing what the trigger fired for. + *

+ * {@code false} for the plain event triggers, whose handler has nothing to + * say beyond the event itself; {@code true} for triggers rendered into + * something that repeats, such as a row renderer. Inputs that read the + * context ({@link ContextInput}) reject a trigger that supplies none, so + * the mistake surfaces on the server instead of as an undefined value in + * the browser. + * + * @return whether a context is supplied, {@code false} by default + */ + public boolean suppliesContext() { + return false; + } + /** * The host element this trigger fires on. * diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java index 799e632cd82..b5b4f651fd5 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardAction.java @@ -242,7 +242,7 @@ protected JsFunction toPromiseJs(Trigger trigger) { JsFunction image = imageInput != null ? imageInput.toJs(trigger) : NULL_INPUT_FN; return JsFunction.of( - "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event), $2(event))", - text, html, image).withArguments("event"); + "return window.Vaadin.Flow.clipboard.writePayload($0(event, context), $1(event, context), $2(event, context))", + text, html, image).withArguments("event", "context"); } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java b/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java new file mode 100644 index 00000000000..fd41188c798 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/trigger/package-info.java @@ -0,0 +1,27 @@ +/* + * 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. + */ +/** + * Actions that run in the browser when something fires them, and the values + * they read when they do. + *

+ * Most applications never touch this package: they bind an action to a + * component through a feature facade ({@code Clipboard.onClick(button)}, + * {@code Fullscreen.onClick(button)}). The types here are for the case where + * the thing that fires the action is rendered on the client and has no + * server-side component of its own — a {@code LitRenderer} row template, for + * example. See {@link com.vaadin.flow.component.trigger.ClientAction}. + */ +package com.vaadin.flow.component.trigger; diff --git a/flow-server/src/test/java/com/vaadin/flow/component/fullscreen/FullscreenTest.java b/flow-server/src/test/java/com/vaadin/flow/component/fullscreen/FullscreenTest.java index 359b143e78f..2a838a0fd0c 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/fullscreen/FullscreenTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/fullscreen/FullscreenTest.java @@ -101,7 +101,7 @@ void enter_withCallbacks_wrapsWithObserver() { }); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals( @@ -137,7 +137,7 @@ void enterComponent_withCallbacks_wrapsWithObserver() { }); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals( diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/ClientActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ClientActionTest.java new file mode 100644 index 00000000000..e7f866f9973 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/ClientActionTest.java @@ -0,0 +1,153 @@ +/* + * 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; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.clipboard.Clipboard; +import com.vaadin.flow.component.trigger.internal.ClickTrigger; +import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.JsFunction; +import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration; +import com.vaadin.flow.shared.Registration; +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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the client-action seam: an action bound to a sink instead of to a + * component, so a renderer can fire it once per element it renders on the + * client while the server holds a single binding. + */ +class ClientActionTest { + + private static class Div extends Component { + Div() { + super(new Element("div")); + } + } + + /** + * Stands in for the renderer: keeps the rendered action functions so they + * can be handed to the client-side template. + */ + private static class RecordingSink implements ClientActionSink { + + private final List installed = new ArrayList<>(); + + @Override + public Registration install(JsFunction action) { + installed.add(action); + return () -> installed.remove(action); + } + } + + @Test + void itemProperty_actionReadsValueFromTheRowItFiredFor() { + UI ui = new MockUI(); + Div container = new Div(); + ui.add(container); + RecordingSink sink = new RecordingSink(); + + Clipboard.write().text(ClientValue.itemProperty("email")) + .bindTo(container.getElement(), sink); + + // One binding for the whole renderer, whatever the row count. + assertEquals(1, sink.installed.size()); + JsFunction action = sink.installed.get(0); + + // The renderer calls the function from its own event binding and + // supplies the row it fired for as the context argument. + assertEquals(List.of("event", "context"), action.getArgumentNames()); + assertEquals( + "return window.Vaadin.Flow.clipboard.writePayload($0(event, context), $1(event, context), $2(event, context))", + action.getBody()); + + JsFunction text = (JsFunction) action.getCaptures().get(0); + assertEquals(List.of("event", "context"), text.getArgumentNames()); + assertEquals("return context[$0][$1]", text.getBody()); + assertEquals(List.of("item", "email"), text.getCaptures()); + } + + @Test + void unarmedCheckPasses_andRegistrationDetachesTheBinding() { + UI ui = new MockUI(); + Div container = new Div(); + ui.add(container); + RecordingSink sink = new RecordingSink(); + + Registration registration = Clipboard.write() + .text(ClientValue.itemProperty("email")) + .bindTo(container.getElement(), sink); + + // The action is committed as part of binding, so the trigger is armed + // and the before-response check does not fail. + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + + registration.remove(); + assertTrue(sink.installed.isEmpty(), + "removing the registration must detach the installed function"); + } + + @Test + void observedWrite_reportsOutcomeThroughTheContainerNode() { + UI ui = new MockUI(); + Div container = new Div(); + ui.add(container); + RecordingSink sink = new RecordingSink(); + List copied = new ArrayList<>(); + + Clipboard.write() + .text(ClientValue.itemProperty("email"), copied::add, + error -> copied.add("error: " + error.name())) + .bindTo(container.getElement(), sink); + + // The outcome comes back over a return channel on the renderer's own + // node — the row identity is not part of it, only the copied value. + JsFunction action = sink.installed.get(0); + Object channel = action.getCaptures().stream() + .filter(ReturnChannelRegistration.class::isInstance).findFirst() + .orElseThrow(); + assertInstanceOf(ReturnChannelRegistration.class, channel); + assertFalse(action.getBody().contains("context["), + "the outcome wrapper itself carries no context"); + } + + @Test + void contextValue_onATriggerWithoutContext_failsOnTheServer() { + // A context-dependent value bound to a plain click trigger would + // evaluate to undefined in the browser; it is rejected here instead. + UI ui = new MockUI(); + Div button = new Div(); + ui.add(button); + + assertThrows(IllegalArgumentException.class, + () -> new ClickTrigger(button) + .triggers(new WriteToClipboardAction( + ClientValue.itemProperty("email").getInput(), + null))); + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/CrossPackageExtensionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/CrossPackageExtensionTest.java index eaf3ea35ed6..25c2ceef7dc 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/CrossPackageExtensionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/CrossPackageExtensionTest.java @@ -63,8 +63,9 @@ private static class LogAction extends Action { @Override protected JsFunction toJs(Trigger trigger) { return JsFunction - .of("console.log($0(event))", message.toJs(trigger)) - .withArguments("event"); + .of("console.log($0(event, context))", + message.toJs(trigger)) + .withArguments("event", "context"); } JsFunction render(Trigger trigger) { @@ -93,7 +94,7 @@ void customAction_consumesInputItDidNotCreate() { JsFunction rendered = action.render(click); - assertEquals("console.log($0(event))", rendered.getBody()); + assertEquals("console.log($0(event, context))", rendered.getBody()); JsFunction source = (JsFunction) rendered.getCaptures().get(0); assertEquals("return event[$0]", source.getBody()); assertEquals("screenX", source.getCaptures().get(0)); diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/CallbackActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/CallbackActionTest.java index cb60283d80b..7a555dde032 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/CallbackActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/CallbackActionTest.java @@ -71,7 +71,7 @@ void handlerBody_callsChannelWithSourceExpression() { JsFunction action = actionOf(singleInstallFn(ui)); // $0 = the return channel; $1 = the source input's JsFunction. The // body forwards the source-fn's value straight into the channel call. - assertEquals("$0($1(event));", action.getBody()); + assertEquals("$0($1(event, context));", action.getBody()); assertEquals(2, action.getCaptures().size()); assertTrue( action.getCaptures() diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DomEventTriggerTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DomEventTriggerTest.java index eb0d89a06a1..7ef06c85288 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DomEventTriggerTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DomEventTriggerTest.java @@ -54,11 +54,12 @@ void setProperty_emitsAddEventListenerOnAttach() { // The install $0 is the action's JsFunction directly — no intermediate // composed handler layer. The action is also the DOM event listener. JsFunction action = actionOf(install); - assertEquals(List.of("event"), action.getArgumentNames()); + assertEquals(List.of("event", "context"), action.getArgumentNames()); // SetPropertyAction body shape; target captured as $0, property name - // string capture at $1, source JsFunction invoked as $2(event). - assertEquals("$0[$1] = $2(event)", action.getBody()); + // string capture at $1, source JsFunction invoked as $2(event, + // context). + assertEquals("$0[$1] = $2(event, context)", action.getBody()); assertSame(field.getElement(), action.getCaptures().get(0)); assertEquals("value", action.getCaptures().get(1)); } diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DownloadActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DownloadActionTest.java index d22a3260463..0610eb26850 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DownloadActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/DownloadActionTest.java @@ -44,7 +44,7 @@ void urlString_actionCallsStartWithOneArgument() { ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("window.Vaadin.Flow.download.start($0(event))", + assertEquals("window.Vaadin.Flow.download.start($0(event, context))", action.getBody()); assertLiteralInputValue(action, 0, "/api/report.pdf"); } @@ -64,7 +64,8 @@ void urlStringWithFilename_actionCallsStartWithTwoArguments() { // verbatim into the input function's capture — Jackson handles the // escaping on the wire, no hand-quoting in the JS. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("window.Vaadin.Flow.download.start($0(event), $1(event))", + assertEquals( + "window.Vaadin.Flow.download.start($0(event, context), $1(event, context))", action.getBody()); assertLiteralInputValue(action, 0, "/api/report.pdf"); assertLiteralInputValue(action, 1, "Q1 \"report\".pdf"); diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabActionTest.java index 46c658a6f63..1c3d32dc7c7 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/OpenInNewTabActionTest.java @@ -30,7 +30,7 @@ class OpenInNewTabActionTest { private static final String EXPECTED_BODY = "((u) =>" + " /^[\\x00-\\x20]*javascript:/i.test(String(u))" - + " || window.open(u, \"_blank\", $1(event)))($0(event))"; + + " || window.open(u, \"_blank\", $1(event, context)))($0(event, context))"; @Test void urlString_emitsWindowOpenWithBlankTargetAndDefaultFeatures() { diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/PromiseActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/PromiseActionTest.java index f864d7bb881..b01da899851 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/PromiseActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/PromiseActionTest.java @@ -78,7 +78,7 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { // $1 is the inner JsFunction returning the promise, $2 is the return // channel. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); List<@Nullable Object> captures = action.getCaptures(); assertEquals(3, captures.size(), diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardActionTest.java index a8366ef35f2..3ade3a81bd4 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/ReadFromClipboardActionTest.java @@ -54,7 +54,7 @@ void actionFnWrapsClipboardReadPromiseWithObserverAndChannel() { // Action wraps the inner promise function with OBSERVE_PROMISE + // return channel. The inner just invokes the Clipboard.ts helper. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals("return window.Vaadin.Flow.clipboard.readPayload()", diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/RequestFullscreenActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/RequestFullscreenActionTest.java index aa5ad0a7a0c..d9febd2c6eb 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/RequestFullscreenActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/RequestFullscreenActionTest.java @@ -74,7 +74,7 @@ void pageMode_withCallbacks_wrapsRequestPageFullscreenWithObserver() { // With callbacks: outer function is the observer wrapper; the inner // function is captured at $1 and contains the actual fullscreen call. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals( @@ -117,7 +117,7 @@ void componentMode_withCallbacks_wrapsRequestComponentFullscreenWithObserver() { ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals( 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 index ced4f71b175..8fd0b691b49 100644 --- 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 @@ -45,7 +45,7 @@ void fireAndForget_allSlots_emitsNavigatorShareWithEachInputFunction() { // stringified into the body. JsFunction action = actionOf(singleInstallFn(ui)); assertEquals( - "return navigator.share({title:$0(event),text:$1(event),url:$2(event)})", + "return navigator.share({title:$0(event, context),text:$1(event, context),url:$2(event, context)})", action.getBody()); assertEquals("Hi", ((JsFunction) action.getCaptures().get(0)) .getCaptures().get(0)); @@ -68,7 +68,7 @@ void fireAndForget_onlyUrl_emitsObjectWithSingleField() { ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("return navigator.share({url:$0(event)})", + assertEquals("return navigator.share({url:$0(event, context)})", action.getBody()); assertEquals("https://vaadin.com", ((JsFunction) action.getCaptures().get(0)).getCaptures() @@ -92,10 +92,10 @@ void withCallbacks_wrapsInnerNavigatorSharePromiseWithObserver() { // 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()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); - assertEquals("return navigator.share({title:$0(event)})", + assertEquals("return navigator.share({title:$0(event, context)})", inner.getBody()); } diff --git a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java index b8e01668054..535525ad829 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/trigger/internal/WriteToClipboardActionTest.java @@ -38,7 +38,7 @@ class WriteToClipboardActionTest { - private static final String HELPER_BODY = "return window.Vaadin.Flow.clipboard.writePayload($0(event), $1(event), $2(event))"; + private static final String HELPER_BODY = "return window.Vaadin.Flow.clipboard.writePayload($0(event, context), $1(event, context), $2(event, context))"; @Test void fireAndForget_textOnly_callsHelperWithHtmlAndImageSlotsReturningNull() { @@ -200,7 +200,7 @@ void withCallbacks_actionFnWrapsInnerWithObserverAndChannel() { // calls writePayload — the action class itself does no string // assembly beyond the static body constant. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals(HELPER_BODY, inner.getBody()); @@ -223,7 +223,7 @@ void withCallbacks_imageOnly_wrapsInnerWithObserverAndChannel() { // The dedicated image observed constructor produces the same outer // shape as the text/html one — only the inner $2 (image) slot differs. JsFunction action = actionOf(singleInstallFn(ui)); - assertEquals("$0($1(event), $2)", action.getBody()); + assertEquals("$0($1(event, context), $2)", action.getBody()); JsFunction inner = (JsFunction) action.getCaptures().get(1); assertEquals(HELPER_BODY, inner.getBody()); 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 index 88df914f8ab..fe4059b47dd 100644 --- 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 @@ -80,7 +80,7 @@ void share_literalSlots_emitsNavigatorShareWithAllFields() { // 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)})", + "return navigator.share({title:$0(event, context),text:$1(event, context),url:$2(event, context)})", handler.getBody()); assertEquals("Hi", ((JsFunction) handler.getCaptures().get(0)) .getCaptures().get(0)); @@ -104,7 +104,7 @@ void share_titleFromHasValue_emitsPropertyInputForValue() { // 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)})", + assertEquals("return navigator.share({title:$0(event, context)})", handler.getBody()); JsFunction titleInput = (JsFunction) handler.getCaptures().get(0); assertEquals("return $0[$1]", titleInput.getBody());