,
+ extras: HTMLElement[]
+ ) => { run(): void }
+ ): void;
+}
+
+const triggers = (window as unknown as {
+ Vaadin?: { Flow?: { triggers?: TriggersRegistry } };
+}).Vaadin?.Flow?.triggers;
+
+if (!triggers) {
+ console.debug(
+ 'window.Vaadin.Flow.triggers not available — flash-action.ts loaded too early'
+ );
+} else {
+ triggers.registerAction('demo:flash', (config, extras) => {
+ const elementIndex = Number(config.element ?? 0);
+ return {
+ run() {
+ // elementIndex 0 means "host"; this action only supports an extra
+ // target. extras[i] is the (i+1)-th referenced element.
+ const target =
+ elementIndex === 0 ? null : extras[elementIndex - 1] ?? null;
+ if (!target) {
+ return;
+ }
+ const originalBg = target.style.backgroundColor;
+ const originalTransition = target.style.transition;
+ target.style.transition = 'background-color 200ms';
+ target.style.backgroundColor = 'var(--aura-yellow, gold)';
+ window.setTimeout(() => {
+ target.style.backgroundColor = originalBg;
+ window.setTimeout(() => {
+ target.style.transition = originalTransition;
+ }, 220);
+ }, 220);
+ }
+ };
+ });
+}
+
+export {};
diff --git a/triggers/src/main/frontend/index.html b/triggers/src/main/frontend/index.html
new file mode 100644
index 0000000..eb0c53b
--- /dev/null
+++ b/triggers/src/main/frontend/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/triggers/src/main/java/com/example/Application.java b/triggers/src/main/java/com/example/Application.java
new file mode 100644
index 0000000..93340d1
--- /dev/null
+++ b/triggers/src/main/java/com/example/Application.java
@@ -0,0 +1,21 @@
+package com.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+import com.vaadin.flow.component.dependency.StyleSheet;
+import com.vaadin.flow.component.page.AppShellConfigurator;
+import com.vaadin.flow.component.page.Push;
+import com.vaadin.flow.theme.aura.Aura;
+
+@SpringBootApplication
+@StyleSheet(Aura.STYLESHEET)
+@StyleSheet("styles.css")
+@Push
+public class Application implements AppShellConfigurator {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Application.class, args);
+ }
+
+}
diff --git a/triggers/src/main/java/com/example/home/HomeView.java b/triggers/src/main/java/com/example/home/HomeView.java
new file mode 100644
index 0000000..a40eb55
--- /dev/null
+++ b/triggers/src/main/java/com/example/home/HomeView.java
@@ -0,0 +1,48 @@
+package com.example.home;
+
+import com.example.common.BaseHomeView;
+import com.example.uc1.CopyFieldValueView;
+import com.example.uc2.CopyCodeSnippetView;
+import com.example.uc3.CopySelectValueView;
+import com.example.uc4.ShareUrlView;
+import com.example.uc5.CustomActionView;
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.Route;
+
+@Route(value = "", layout = MainLayout.class)
+@Menu(order = 0, title = "Home")
+public class HomeView extends BaseHomeView {
+
+ public HomeView() {
+ super("Trigger / Action API — use cases",
+ "Each card below exercises one use case of the new "
+ + "com.vaadin.flow.component.trigger API. A Trigger fires on "
+ + "the client (e.g. a click), reads zero or more Outputs from "
+ + "the DOM, and runs one or more Actions — all inside the "
+ + "original user-gesture handler, so browser APIs gated on a "
+ + "gesture (clipboard, fullscreen, share) work without a "
+ + "server round-trip.");
+
+ Div cards = new Div();
+ cards.addClassName("home-cards");
+ cards.add(homeCard("UC1", "Copy field value",
+ "Click a button, copy the current value of a text field.",
+ CopyFieldValueView.class));
+ cards.add(homeCard("UC2", "Copy code snippet",
+ "Copy a block's textContent — Output works on any element.",
+ CopyCodeSnippetView.class));
+ cards.add(homeCard("UC3", "Copy from a select",
+ "Read the currently-selected option's value via PropertyOutput.",
+ CopySelectValueView.class));
+ cards.add(homeCard("UC4", "Share-URL widget",
+ "Server-generated URL, copied without a server round-trip.",
+ ShareUrlView.class));
+ cards.add(homeCard("UC5", "Custom action",
+ "Register a custom action on the client via @JsModule.",
+ CustomActionView.class));
+ add(cards);
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc1/CopyFieldValueView.java b/triggers/src/main/java/com/example/uc1/CopyFieldValueView.java
new file mode 100644
index 0000000..762b92c
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc1/CopyFieldValueView.java
@@ -0,0 +1,55 @@
+package com.example.uc1;
+
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.Output;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+/**
+ * UC1 — Copy a text field's current value to the clipboard.
+ *
+ * Wires the canonical trio: a {@link ClickTrigger} on a button, a
+ * {@link PropertyOutput} reading the field's {@code value} property, and a
+ * {@link ClipboardCopyAction}. The whole chain runs in the click handler on
+ * the client, so {@code navigator.clipboard.writeText} sees a real user
+ * gesture and the browser allows the write — no server round-trip needed.
+ */
+@Route(value = "uc1", layout = MainLayout.class)
+@PageTitle("UC1 — Copy field value")
+@Menu(order = 1, title = "UC1 — Copy field value")
+public class CopyFieldValueView extends VerticalLayout {
+
+ public CopyFieldValueView() {
+ add(new H1("UC1 — Copy field value on click"));
+ add(new Paragraph(
+ "Type something and press \"Copy\". The button's ClickTrigger "
+ + "reads the field's value via a PropertyOutput and feeds it "
+ + "to a ClipboardCopyAction — all in the click handler so "
+ + "the browser allows the clipboard write."));
+
+ TextField field = new TextField("Text to copy");
+ field.setId("source");
+ field.setValue("Hello clipboard");
+ field.setWidth("22rem");
+
+ Button copy = new Button("Copy");
+ copy.setId("copy");
+
+ Output value = new PropertyOutput<>(field, "value",
+ String.class);
+ new ClickTrigger(copy).triggers(new ClipboardCopyAction(value));
+
+ add(new HorizontalLayout(field, copy));
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc2/CopyCodeSnippetView.java b/triggers/src/main/java/com/example/uc2/CopyCodeSnippetView.java
new file mode 100644
index 0000000..661c831
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc2/CopyCodeSnippetView.java
@@ -0,0 +1,58 @@
+package com.example.uc2;
+
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.dependency.StyleSheet;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.component.html.Pre;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.Output;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+/**
+ * UC2 — Copy a code snippet's text on click.
+ *
+ * Same wiring as UC1, but the {@link PropertyOutput} reads the
+ * {@code textContent} property of a {@code
} element instead of a form
+ * field's {@code value}. Demonstrates that PropertyOutput is not specific to
+ * form inputs — any DOM element with a readable property works.
+ */
+@Route(value = "uc2", layout = MainLayout.class)
+@PageTitle("UC2 — Copy code snippet")
+@Menu(order = 2, title = "UC2 — Copy code snippet")
+@StyleSheet("uc2.css")
+public class CopyCodeSnippetView extends VerticalLayout {
+
+ private static final String SNIPPET = """
+ new ClickTrigger(button).triggers(
+ new ClipboardCopyAction(
+ new PropertyOutput<>(field, "value", String.class)));""";
+
+ public CopyCodeSnippetView() {
+ addClassName("uc2-view");
+ add(new H1("UC2 — Copy a code snippet"));
+ add(new Paragraph(
+ "PropertyOutput can read any JS property from any element. "
+ + "Here it reads the textContent of the below."));
+
+ Pre snippet = new Pre(SNIPPET);
+ snippet.setId("snippet");
+ snippet.addClassName("code-snippet");
+
+ Button copy = new Button("Copy snippet");
+ copy.setId("copy");
+
+ Output text = new PropertyOutput<>(snippet, "textContent",
+ String.class);
+ new ClickTrigger(copy).triggers(new ClipboardCopyAction(text));
+
+ add(snippet, copy);
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc3/CopySelectValueView.java b/triggers/src/main/java/com/example/uc3/CopySelectValueView.java
new file mode 100644
index 0000000..c16f174
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc3/CopySelectValueView.java
@@ -0,0 +1,67 @@
+package com.example.uc3;
+
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.Output;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+/**
+ * UC3 — Copy the currently-selected option's value from a native
+ * {@code }.
+ *
+ * Uses the raw {@link Element} API to build the select so the demo stays
+ * close to the plain DOM the {@link PropertyOutput} talks to. Reading
+ * {@code .value} returns the value of whichever option is currently
+ * selected, exactly like any other JS property.
+ */
+@Route(value = "uc3", layout = MainLayout.class)
+@PageTitle("UC3 — Copy current select value")
+@Menu(order = 3, title = "UC3 — Copy current select value")
+public class CopySelectValueView extends VerticalLayout {
+
+ public CopySelectValueView() {
+ add(new H1("UC3 — Copy the currently selected option"));
+ add(new Paragraph(
+ "PropertyOutput reads the element's value property, "
+ + "which is the value of whichever option is currently "
+ + "selected. Picking a different option changes what "
+ + "lands on the clipboard the next time the button is "
+ + "clicked."));
+
+ Element select = new Element("select");
+ select.setAttribute("id", "fruit");
+ select.appendChild(option("apple", "Apple"));
+ select.appendChild(option("banana", "Banana"));
+ select.appendChild(option("cherry", "Cherry"));
+
+ Button copy = new Button("Copy selection");
+ copy.setId("copy");
+
+ Output value = new PropertyOutput<>(select, "value",
+ String.class);
+ new ClickTrigger(copy).triggers(new ClipboardCopyAction(value));
+
+ HorizontalLayout row = new HorizontalLayout();
+ row.getElement().appendChild(select);
+ row.add(copy);
+ add(row);
+ }
+
+ private static Element option(String value, String label) {
+ Element option = new Element("option");
+ option.setAttribute("value", value);
+ option.setText(label);
+ return option;
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc4/ShareUrlView.java b/triggers/src/main/java/com/example/uc4/ShareUrlView.java
new file mode 100644
index 0000000..def58d2
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc4/ShareUrlView.java
@@ -0,0 +1,65 @@
+package com.example.uc4;
+
+import java.util.UUID;
+
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.dependency.StyleSheet;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.Output;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+/**
+ * UC4 — Server-generated share URL, copied without a server round-trip.
+ *
+ * Highlights the main reason the trigger API exists: the clipboard write must
+ * happen synchronously inside the click handler or the browser refuses it. The
+ * server renders the URL into a field at view-construction time; from that
+ * point on the copy is pure client behaviour and works the first time the user
+ * clicks.
+ */
+@Route(value = "uc4", layout = MainLayout.class)
+@PageTitle("UC4 — Share URL")
+@Menu(order = 4, title = "UC4 — Share URL widget")
+@StyleSheet("uc4.css")
+public class ShareUrlView extends VerticalLayout {
+
+ public ShareUrlView() {
+ addClassName("uc4-view");
+ add(new H1("UC4 — Share URL widget"));
+ add(new Paragraph(
+ "The URL below is generated on the server when the view is "
+ + "rendered. Copying it does not require a round-trip — the "
+ + "click handler reads the field's current value and copies "
+ + "it inside the user gesture, which is the only time the "
+ + "browser permits a clipboard write."));
+
+ String shareUrl = "https://example.com/share/"
+ + UUID.randomUUID().toString().substring(0, 8);
+
+ TextField field = new TextField();
+ field.setId("share-url");
+ field.setValue(shareUrl);
+ field.setReadOnly(true);
+ field.addClassName("share-url-field");
+
+ Button copy = new Button("Copy link");
+ copy.setId("copy");
+
+ Output value = new PropertyOutput<>(field, "value",
+ String.class);
+ new ClickTrigger(copy).triggers(new ClipboardCopyAction(value));
+
+ add(new HorizontalLayout(field, copy));
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc5/CustomActionView.java b/triggers/src/main/java/com/example/uc5/CustomActionView.java
new file mode 100644
index 0000000..aa9cea4
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc5/CustomActionView.java
@@ -0,0 +1,54 @@
+package com.example.uc5;
+
+import com.example.views.MainLayout;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.dependency.JsModule;
+import com.vaadin.flow.component.dependency.StyleSheet;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Paragraph;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+/**
+ * UC5 — Run an application-defined action on click.
+ *
+ * Demonstrates the extension SPI: a custom {@link FlashAction} that ships
+ * with this view briefly flashes the target's background when clicked. The
+ * server side is a 30-line subclass of
+ * {@link com.vaadin.flow.component.trigger.AbstractAction}; the client side
+ * is a small TS module ({@code flash-action.ts}) that registers a factory
+ * against {@code window.Vaadin.Flow.triggers} under the same type id.
+ */
+@Route(value = "uc5", layout = MainLayout.class)
+@PageTitle("UC5 — Custom action via @JsModule")
+@Menu(order = 5, title = "UC5 — Custom action")
+@JsModule("./flash-action.ts")
+@StyleSheet("uc5.css")
+public class CustomActionView extends VerticalLayout {
+
+ public CustomActionView() {
+ addClassName("uc5-view");
+ add(new H1("UC5 — Custom action via @JsModule"));
+ add(new Paragraph(
+ "Click \"Flash\" to fire a FlashAction defined in this app. "
+ + "FlashAction is a com.vaadin.flow.component.trigger."
+ + "AbstractAction with type id \"demo:flash\"; "
+ + "flash-action.ts registers the matching client factory."));
+
+ Div target = new Div("Flash me");
+ target.setId("target");
+ target.addClassName("flash-target");
+
+ Button trigger = new Button("Flash");
+ trigger.setId("trigger");
+
+ new ClickTrigger(trigger).triggers(new FlashAction(target));
+
+ add(target, trigger);
+ }
+}
diff --git a/triggers/src/main/java/com/example/uc5/FlashAction.java b/triggers/src/main/java/com/example/uc5/FlashAction.java
new file mode 100644
index 0000000..af5a082
--- /dev/null
+++ b/triggers/src/main/java/com/example/uc5/FlashAction.java
@@ -0,0 +1,47 @@
+package com.example.uc5;
+
+import java.util.Objects;
+
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.trigger.AbstractAction;
+import com.vaadin.flow.component.trigger.internal.ConfigContext;
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.internal.JacksonUtils;
+
+/**
+ * Custom action that briefly flashes the target element's background.
+ *
+ * Demonstrates the extension SPI: namespaced type id, an
+ * {@link com.vaadin.flow.component.trigger.AbstractAction} subclass on the
+ * server, and a matching factory registered against
+ * {@code window.Vaadin.Flow.triggers} by the {@code flash-action.ts} module
+ * that ships with this view.
+ */
+public class FlashAction extends AbstractAction {
+
+ public static final String TYPE_ID = "demo:flash";
+
+ private final Element target;
+
+ public FlashAction(Component target) {
+ this(Objects.requireNonNull(target).getElement());
+ }
+
+ public FlashAction(Element target) {
+ super(TYPE_ID);
+ this.target = Objects.requireNonNull(target);
+ }
+
+ public Element getTarget() {
+ return target;
+ }
+
+ @Override
+ public ObjectNode buildClientConfig(ConfigContext context) {
+ ObjectNode node = JacksonUtils.createObjectNode();
+ node.put("element", context.referenceElement(target));
+ return node;
+ }
+}
diff --git a/triggers/src/main/java/com/example/views/MainLayout.java b/triggers/src/main/java/com/example/views/MainLayout.java
new file mode 100644
index 0000000..78ff353
--- /dev/null
+++ b/triggers/src/main/java/com/example/views/MainLayout.java
@@ -0,0 +1,13 @@
+package com.example.views;
+
+import com.example.common.BaseMainLayout;
+
+import com.vaadin.flow.router.PageTitle;
+
+@PageTitle("Trigger / Action API Use Cases")
+public class MainLayout extends BaseMainLayout {
+
+ public MainLayout() {
+ super("triggers", "Trigger / Action API Use Cases");
+ }
+}
diff --git a/triggers/src/main/resources/META-INF/resources/styles.css b/triggers/src/main/resources/META-INF/resources/styles.css
new file mode 100644
index 0000000..7e83f8b
--- /dev/null
+++ b/triggers/src/main/resources/META-INF/resources/styles.css
@@ -0,0 +1,15 @@
+/* Trigger / Action API use cases — cross-view styling */
+
+.home-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 1rem;
+ max-width: 960px;
+}
+
+.home-card .home-card-tag {
+ font-size: 0.8125rem;
+ text-transform: uppercase;
+ color: var(--vaadin-text-color-secondary);
+ letter-spacing: 0.05em;
+}
diff --git a/triggers/src/main/resources/META-INF/resources/uc2.css b/triggers/src/main/resources/META-INF/resources/uc2.css
new file mode 100644
index 0000000..268aaa9
--- /dev/null
+++ b/triggers/src/main/resources/META-INF/resources/uc2.css
@@ -0,0 +1,13 @@
+.uc2-view {
+ .code-snippet {
+ font-family: monospace;
+ font-size: 0.9rem;
+ background: var(--vaadin-background-container);
+ border: 1px solid var(--vaadin-border-color);
+ border-radius: var(--vaadin-radius-m);
+ padding: 0.75rem 1rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+ }
+}
diff --git a/triggers/src/main/resources/META-INF/resources/uc4.css b/triggers/src/main/resources/META-INF/resources/uc4.css
new file mode 100644
index 0000000..784cb19
--- /dev/null
+++ b/triggers/src/main/resources/META-INF/resources/uc4.css
@@ -0,0 +1,6 @@
+.uc4-view {
+ .share-url-field {
+ width: 22rem;
+ max-width: 100%;
+ }
+}
diff --git a/triggers/src/main/resources/META-INF/resources/uc5.css b/triggers/src/main/resources/META-INF/resources/uc5.css
new file mode 100644
index 0000000..622afae
--- /dev/null
+++ b/triggers/src/main/resources/META-INF/resources/uc5.css
@@ -0,0 +1,8 @@
+.uc5-view {
+ .flash-target {
+ padding: 1rem;
+ border: 1px solid var(--vaadin-border-color);
+ border-radius: var(--vaadin-radius-m);
+ width: fit-content;
+ }
+}
diff --git a/triggers/src/main/resources/application.properties b/triggers/src/main/resources/application.properties
new file mode 100644
index 0000000..a2c369f
--- /dev/null
+++ b/triggers/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+server.port=${PORT:8080}
+logging.level.org.atmosphere=warn
+
+vaadin.launch-browser=true
+vaadin.allowed-packages=com.vaadin,org.vaadin,com.example
+vaadin.frontend.hotdeploy=true
diff --git a/triggers/src/test/java/com/example/uc1/CopyFieldValueViewTest.java b/triggers/src/test/java/com/example/uc1/CopyFieldValueViewTest.java
new file mode 100644
index 0000000..9fce991
--- /dev/null
+++ b/triggers/src/test/java/com/example/uc1/CopyFieldValueViewTest.java
@@ -0,0 +1,69 @@
+package com.example.uc1;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.component.trigger.internal.TriggerSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest
+@ViewPackages(classes = CopyFieldValueView.class)
+class CopyFieldValueViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void viewRendersWithExpectedComponents() {
+ navigate(CopyFieldValueView.class);
+
+ assertTrue($view(H1.class).all().stream()
+ .anyMatch(h -> "UC1 — Copy field value on click"
+ .equals(h.getText())));
+ assertNotNull($view(TextField.class).id("source"));
+ assertNotNull($view(Button.class).id("copy"));
+ }
+
+ @Test
+ void clickTriggerIsWiredToClipboardCopyActionReadingFieldValue() {
+ navigate(CopyFieldValueView.class);
+
+ Button copy = $view(Button.class).id("copy");
+ ObjectNode snapshot = TriggerSupport.on(copy).snapshotForTest();
+
+ JsonNode triggers = snapshot.get("triggers");
+ assertEquals(1, triggers.size(), "exactly one trigger");
+ assertEquals(ClickTrigger.TYPE_ID,
+ triggers.get("0").get("type").asString());
+
+ JsonNode actions = snapshot.get("actions");
+ assertEquals(1, actions.size(), "exactly one action");
+ JsonNode actionEntry = actions.get("0");
+ assertEquals(ClipboardCopyAction.TYPE_ID,
+ actionEntry.get("type").asString());
+
+ int outputId = actionEntry.get("config").get("textOutput").asInt();
+ JsonNode outputs = snapshot.get("outputs");
+ JsonNode outputEntry = outputs.get(Integer.toString(outputId));
+ assertEquals(PropertyOutput.TYPE_ID,
+ outputEntry.get("type").asString());
+ assertEquals("value",
+ outputEntry.get("config").get("property").asString());
+
+ JsonNode bindings = snapshot.get("bindings");
+ assertEquals(1, bindings.size());
+ assertEquals(0, bindings.get(0).get("trigger").asInt());
+ assertEquals(1, bindings.get(0).get("actions").size());
+ assertEquals(0, bindings.get(0).get("actions").get(0).asInt());
+ }
+}
diff --git a/triggers/src/test/java/com/example/uc2/CopyCodeSnippetViewTest.java b/triggers/src/test/java/com/example/uc2/CopyCodeSnippetViewTest.java
new file mode 100644
index 0000000..ee59fb9
--- /dev/null
+++ b/triggers/src/test/java/com/example/uc2/CopyCodeSnippetViewTest.java
@@ -0,0 +1,64 @@
+package com.example.uc2;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.html.Pre;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.component.trigger.internal.TriggerSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest
+@ViewPackages(classes = CopyCodeSnippetView.class)
+class CopyCodeSnippetViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void viewRendersWithSnippetAndCopyButton() {
+ navigate(CopyCodeSnippetView.class);
+
+ assertTrue($view(H1.class).all().stream()
+ .anyMatch(h -> "UC2 — Copy a code snippet".equals(h.getText())));
+ assertNotNull($view(Pre.class).id("snippet"));
+ assertNotNull($view(Button.class).id("copy"));
+ }
+
+ @Test
+ void outputReadsTextContentOfPreElement() {
+ navigate(CopyCodeSnippetView.class);
+
+ Button copy = $view(Button.class).id("copy");
+ ObjectNode snapshot = TriggerSupport.on(copy).snapshotForTest();
+
+ JsonNode actionEntry = snapshot.get("actions").get("0");
+ assertEquals(ClipboardCopyAction.TYPE_ID,
+ actionEntry.get("type").asString());
+
+ int outputId = actionEntry.get("config").get("textOutput").asInt();
+ JsonNode outputEntry = snapshot.get("outputs")
+ .get(Integer.toString(outputId));
+ assertEquals(PropertyOutput.TYPE_ID,
+ outputEntry.get("type").asString());
+ assertEquals("textContent",
+ outputEntry.get("config").get("property").asString());
+
+ // The pre element is not the host of the trigger, so it shows up as
+ // an extra element parameter (index >= 1).
+ assertTrue(outputEntry.get("config").get("element").asInt() >= 1,
+ "non-host element should get an extra-parameter index");
+
+ JsonNode triggers = snapshot.get("triggers");
+ assertEquals(ClickTrigger.TYPE_ID,
+ triggers.get("0").get("type").asString());
+ }
+}
diff --git a/triggers/src/test/java/com/example/uc3/CopySelectValueViewTest.java b/triggers/src/test/java/com/example/uc3/CopySelectValueViewTest.java
new file mode 100644
index 0000000..cff1225
--- /dev/null
+++ b/triggers/src/test/java/com/example/uc3/CopySelectValueViewTest.java
@@ -0,0 +1,57 @@
+package com.example.uc3;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.component.trigger.internal.TriggerSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest
+@ViewPackages(classes = CopySelectValueView.class)
+class CopySelectValueViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void viewRendersHeadingAndCopyButton() {
+ navigate(CopySelectValueView.class);
+
+ assertTrue($view(H1.class).all().stream()
+ .anyMatch(h -> "UC3 — Copy the currently selected option"
+ .equals(h.getText())));
+ assertNotNull($view(Button.class).id("copy"));
+ }
+
+ @Test
+ void outputReadsValueOfNativeSelect() {
+ navigate(CopySelectValueView.class);
+
+ Button copy = $view(Button.class).id("copy");
+ ObjectNode snapshot = TriggerSupport.on(copy).snapshotForTest();
+
+ JsonNode action = snapshot.get("actions").get("0");
+ assertEquals(ClipboardCopyAction.TYPE_ID,
+ action.get("type").asString());
+
+ int outputId = action.get("config").get("textOutput").asInt();
+ JsonNode output = snapshot.get("outputs")
+ .get(Integer.toString(outputId));
+ assertEquals(PropertyOutput.TYPE_ID, output.get("type").asString());
+ assertEquals("value", output.get("config").get("property").asString());
+ assertTrue(output.get("config").get("element").asInt() >= 1,
+ "select is a non-host element, should get an extra index");
+
+ assertEquals(ClickTrigger.TYPE_ID,
+ snapshot.get("triggers").get("0").get("type").asString());
+ }
+}
diff --git a/triggers/src/test/java/com/example/uc4/ShareUrlViewTest.java b/triggers/src/test/java/com/example/uc4/ShareUrlViewTest.java
new file mode 100644
index 0000000..77958cc
--- /dev/null
+++ b/triggers/src/test/java/com/example/uc4/ShareUrlViewTest.java
@@ -0,0 +1,76 @@
+package com.example.uc4;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.ClipboardCopyAction;
+import com.vaadin.flow.component.trigger.PropertyOutput;
+import com.vaadin.flow.component.trigger.internal.TriggerSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest
+@ViewPackages(classes = ShareUrlView.class)
+class ShareUrlViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void viewRendersWithServerGeneratedUrl() {
+ navigate(ShareUrlView.class);
+
+ assertTrue($view(H1.class).all().stream()
+ .anyMatch(h -> "UC4 — Share URL widget".equals(h.getText())));
+
+ TextField field = $view(TextField.class).id("share-url");
+ assertTrue(field.getValue().startsWith("https://example.com/share/"),
+ "server should render the share URL into the field");
+ assertTrue(field.isReadOnly(), "share URL is read-only");
+ }
+
+ @Test
+ void copyButtonIsWiredToReadFieldValue() {
+ navigate(ShareUrlView.class);
+
+ Button copy = $view(Button.class).id("copy");
+ ObjectNode snapshot = TriggerSupport.on(copy).snapshotForTest();
+
+ assertEquals(ClickTrigger.TYPE_ID,
+ snapshot.get("triggers").get("0").get("type").asString());
+
+ JsonNode action = snapshot.get("actions").get("0");
+ assertEquals(ClipboardCopyAction.TYPE_ID,
+ action.get("type").asString());
+
+ int outputId = action.get("config").get("textOutput").asInt();
+ JsonNode output = snapshot.get("outputs")
+ .get(Integer.toString(outputId));
+ assertEquals(PropertyOutput.TYPE_ID, output.get("type").asString());
+ assertEquals("value", output.get("config").get("property").asString());
+ }
+
+ @Test
+ void twoSessionsGetDifferentShareUrls() {
+ ShareUrlView first = navigate(ShareUrlView.class);
+ String firstUrl = $view(TextField.class).id("share-url").getValue();
+
+ cleanVaadinEnvironment();
+ initVaadinEnvironment();
+
+ ShareUrlView second = navigate(ShareUrlView.class);
+ String secondUrl = $view(TextField.class).id("share-url").getValue();
+
+ assertTrue(!firstUrl.equals(secondUrl),
+ "each session should generate its own share URL");
+ // Touch the view instances so the compiler doesn't drop the
+ // navigate calls' return values entirely.
+ assertTrue(first != second);
+ }
+}
diff --git a/triggers/src/test/java/com/example/uc5/CustomActionViewTest.java b/triggers/src/test/java/com/example/uc5/CustomActionViewTest.java
new file mode 100644
index 0000000..1143a72
--- /dev/null
+++ b/triggers/src/test/java/com/example/uc5/CustomActionViewTest.java
@@ -0,0 +1,59 @@
+package com.example.uc5;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.node.ObjectNode;
+
+import com.vaadin.browserless.SpringBrowserlessTest;
+import com.vaadin.browserless.ViewPackages;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H1;
+import com.vaadin.flow.component.trigger.ClickTrigger;
+import com.vaadin.flow.component.trigger.internal.TriggerSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest
+@ViewPackages(classes = CustomActionView.class)
+class CustomActionViewTest extends SpringBrowserlessTest {
+
+ @Test
+ void viewRendersTargetAndTriggerButton() {
+ navigate(CustomActionView.class);
+
+ assertTrue($view(H1.class).all().stream()
+ .anyMatch(h -> "UC5 — Custom action via @JsModule"
+ .equals(h.getText())));
+ assertNotNull($view(Div.class).id("target"));
+ assertNotNull($view(Button.class).id("trigger"));
+ }
+
+ @Test
+ void clickIsBoundToCustomActionTypeId() {
+ navigate(CustomActionView.class);
+
+ Button trigger = $view(Button.class).id("trigger");
+ ObjectNode snapshot = TriggerSupport.on(trigger).snapshotForTest();
+
+ assertEquals(ClickTrigger.TYPE_ID,
+ snapshot.get("triggers").get("0").get("type").asString());
+
+ JsonNode action = snapshot.get("actions").get("0");
+ assertEquals(FlashAction.TYPE_ID, action.get("type").asString(),
+ "the action's namespaced type id is what the client factory looks up");
+ assertTrue(action.get("config").get("element").asInt() >= 1,
+ "the target Div is a non-host element, so it gets an extra-parameter index");
+
+ // No outputs needed for this action.
+ assertEquals(0, snapshot.get("outputs").size());
+
+ JsonNode bindings = snapshot.get("bindings");
+ assertEquals(1, bindings.size());
+ assertEquals(0, bindings.get(0).get("trigger").asInt());
+ assertEquals(0, bindings.get(0).get("actions").get(0).asInt());
+ }
+}
diff --git a/triggers/tsconfig.json b/triggers/tsconfig.json
new file mode 100644
index 0000000..c674043
--- /dev/null
+++ b/triggers/tsconfig.json
@@ -0,0 +1,38 @@
+// This TypeScript configuration file is generated by vaadin-maven-plugin.
+// This is needed for TypeScript compiler to compile your TypeScript code in the project.
+// It is recommended to commit this file to the VCS.
+// You might want to change the configurations to fit your preferences
+// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
+{
+ "_version": "9.2",
+ "compilerOptions": {
+ "sourceMap": true,
+ "jsx": "react-jsx",
+ "inlineSources": true,
+ "module": "esNext",
+ "target": "es2023",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitReturns": true,
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "experimentalDecorators": true,
+ "useDefineForClassFields": false,
+ "paths": {
+ "@vaadin/flow-frontend": ["./src/main/frontend/generated/jar-resources"],
+ "@vaadin/flow-frontend/*": ["./src/main/frontend/generated/jar-resources/*"],
+ "Frontend/*": ["./src/main/frontend/*"]
+ }
+ },
+ "include": [
+ "src/main/frontend/**/*",
+ "types.d.ts"
+ ],
+ "exclude": [
+ "src/main/frontend/generated/jar-resources/**"
+ ]
+}
diff --git a/triggers/types.d.ts b/triggers/types.d.ts
new file mode 100644
index 0000000..eff230b
--- /dev/null
+++ b/triggers/types.d.ts
@@ -0,0 +1,17 @@
+// This TypeScript modules definition file is generated by vaadin-maven-plugin.
+// You can not directly import your different static files into TypeScript,
+// This is needed for TypeScript compiler to declare and export as a TypeScript module.
+// It is recommended to commit this file to the VCS.
+// You might want to change the configurations to fit your preferences
+declare module '*.css?inline' {
+ import type { CSSResultGroup } from 'lit';
+ const content: CSSResultGroup;
+ export default content;
+}
+
+// Allow any CSS Custom Properties
+declare module 'csstype' {
+ interface Properties {
+ [index: `--${string}`]: any;
+ }
+}
diff --git a/triggers/vite.config.ts b/triggers/vite.config.ts
new file mode 100644
index 0000000..4d6a022
--- /dev/null
+++ b/triggers/vite.config.ts
@@ -0,0 +1,9 @@
+import { UserConfigFn } from 'vite';
+import { overrideVaadinConfig } from './vite.generated';
+
+const customConfig: UserConfigFn = (env) => ({
+ // Here you can add custom Vite parameters
+ // https://vitejs.dev/config/
+});
+
+export default overrideVaadinConfig(customConfig);