diff --git a/flow-server/src/main/java/com/vaadin/flow/component/UI.java b/flow-server/src/main/java/com/vaadin/flow/component/UI.java index 175ecae6928..44d9e7903f6 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/UI.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/UI.java @@ -108,6 +108,7 @@ * @since 1.0 */ @JsModule("@vaadin/common-frontend/ConnectionIndicator.js") +@JsModule("./clipboard.js") public class UI extends Component implements PollNotifier, HasComponents, RouterLayout { diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java new file mode 100644 index 00000000000..06ba4a2d601 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Clipboard.java @@ -0,0 +1,549 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.page; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import tools.jackson.databind.JsonNode; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.dom.DomListenerRegistration; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.internal.nodefeature.ReturnChannelMap; +import com.vaadin.flow.internal.nodefeature.ReturnChannelRegistration; +import com.vaadin.flow.server.Command; +import com.vaadin.flow.server.StreamRegistration; +import com.vaadin.flow.server.StreamResource; +import com.vaadin.flow.server.streams.UploadHandler; +import com.vaadin.flow.server.streams.UploadMetadata; +import com.vaadin.flow.shared.Registration; + +/** + * Provides access to the browser Clipboard API. + *
+ * This interface offers two categories of clipboard operations: + *
+ * Usage pattern follows {@link WebStorage} — static methods that use + * {@link UI#getCurrentOrThrow()} by default. + */ +public interface Clipboard extends Serializable { + + // --- Client-side copy (reliable, no round-trip) --- + + /** + * Sets up a client-side click handler on the trigger component that copies + * the given text to the clipboard when clicked. + *
+ * The copy operation executes entirely on the client side within the click + * event handler, so it satisfies the user gesture requirement in all + * browsers. + * + * @param trigger + * the component whose clicks trigger the copy + * @param text + * the text to copy to the clipboard + * @return a {@link ClipboardCopy} handle for updating the text or removing + * the handler + */ + static ClipboardCopy copyOnClick(Component trigger, String text) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + + Element element = trigger.getElement(); + element.setProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY, + text != null ? text : ""); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClick(this)"); + + Registration cleanup = Registration.once(() -> { + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the given text to the clipboard, with success and error callbacks. + *
+ * The copy operation executes on the client side. The callbacks are invoked + * on the server after the clipboard operation completes or fails. + * + * @param trigger + * the component whose clicks trigger the copy + * @param text + * the text to copy to the clipboard + * @param onSuccess + * callback invoked on the server when the copy succeeds + * @param onError + * callback invoked on the server when the copy fails + * @return a {@link ClipboardCopy} handle for updating the text or removing + * the handler + */ + static ClipboardCopy copyOnClick(Component trigger, String text, + Command onSuccess, Command onError) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + + Element element = trigger.getElement(); + element.setProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY, + text != null ? text : ""); + + ReturnChannelRegistration successChannel = element.getNode() + .getFeature(ReturnChannelMap.class).registerChannel(args -> { + if (onSuccess != null) { + onSuccess.execute(); + } + }); + + ReturnChannelRegistration errorChannel = element.getNode() + .getFeature(ReturnChannelMap.class).registerChannel(args -> { + if (onError != null) { + onError.execute(); + } + }); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClickWithCallbacks(this, $0, $1)", + successChannel, errorChannel); + + Registration cleanup = Registration.once(() -> { + successChannel.remove(); + errorChannel.remove(); + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the current value of the source component to the clipboard. + *
+ * The source component's value is read client-side (from the DOM element's + * {@code value} or {@code textContent} property) at click time, so no + * server round-trip is needed. + * + * @param trigger + * the component whose clicks trigger the copy + * @param source + * the component whose value to copy + * @return a {@link ClipboardCopy} handle for removing the handler + */ + static ClipboardCopy copyOnClick(Component trigger, Component source) { + Objects.requireNonNull(trigger, "Trigger component must not be null"); + Objects.requireNonNull(source, "Source component must not be null"); + + Element element = trigger.getElement(); + + element.executeJs( + "window.Vaadin.Flow.clipboard.setupCopyOnClickFromSource(this, $0)", + source.getElement()); + + Registration cleanup = Registration.once(() -> { + element.executeJs( + "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)"); + }); + + return new ClipboardCopy(element, cleanup); + } + + /** + * Sets up a client-side click handler on the trigger component that copies + * the image from the given image component to the clipboard. + *
+ * The image source component should have a {@code src} attribute (e.g. an
+ * {@code } element). The image is fetched client-side from the
+ * element's {@code src} attribute and written to the clipboard as a blob.
+ *
+ * @param trigger
+ * the component whose clicks trigger the copy
+ * @param imageSource
+ * the component with a {@code src} attribute pointing to an
+ * image
+ * @return a {@link ClipboardCopy} handle for removing the handler
+ */
+ static ClipboardCopy copyImageOnClick(Component trigger,
+ Component imageSource) {
+ Objects.requireNonNull(trigger, "Trigger component must not be null");
+ Objects.requireNonNull(imageSource,
+ "Image source component must not be null");
+
+ Element element = trigger.getElement();
+
+ element.executeJs(
+ "window.Vaadin.Flow.clipboard.setupCopyImageOnClick(this, $0)",
+ imageSource.getElement());
+
+ Registration cleanup = Registration.once(() -> {
+ element.executeJs(
+ "window.Vaadin.Flow.clipboard.cleanupCopyOnClick(this)");
+ });
+
+ return new ClipboardCopy(element, cleanup);
+ }
+
+ // --- Server-initiated clipboard operations (round-trip, browser-dependent)
+ // ---
+
+ /**
+ * Writes the given text to the clipboard.
+ *
+ * Browser compatibility note: This method involves a server + * round-trip and may not work in Firefox or Safari, which require clipboard + * operations to be performed within a user gesture (click handler call + * stack). Use {@link #copyOnClick(Component, String)} for reliable + * cross-browser clipboard writes. + * + * @param text + * the text to write to the clipboard + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeText(String text) { + return writeText(UI.getCurrentOrThrow(), text); + } + + /** + * Writes the given text to the clipboard using the specified UI. + * + * @param ui + * the UI to use + * @param text + * the text to write to the clipboard + * @return a {@link PendingJavaScriptResult} for the clipboard operation + */ + static PendingJavaScriptResult writeText(UI ui, String text) { + return ui.getPage().executeJs( + "return window.Vaadin.Flow.clipboard.writeText($0)", text); + } + + /** + * Writes the given text to the clipboard with success and error callbacks. + *
+ * Browser compatibility note: This method involves a server
+ * round-trip and may not work in Firefox or Safari.
+ *
+ * @param text
+ * the text to write
+ * @param onSuccess
+ * callback invoked when the write succeeds
+ * @param onError
+ * callback invoked with an error message when the write fails
+ */
+ static void writeText(String text, Command onSuccess,
+ SerializableConsumer
+ * Browser compatibility note: This method involves a server
+ * round-trip. The browser may prompt the user for permission to read the
+ * clipboard.
+ *
+ * @param callback
+ * callback invoked with the clipboard text
+ */
+ static void readText(SerializableConsumer
+ * The browser fetches the image from the URL client-side and writes it to
+ * the clipboard as a PNG blob.
+ *
+ * Browser compatibility note: This method involves a server
+ * round-trip for delivering the JavaScript command.
+ *
+ * @param imageUrl
+ * the URL of the image to copy
+ * @return a {@link PendingJavaScriptResult} for the clipboard operation
+ */
+ static PendingJavaScriptResult writeImage(String imageUrl) {
+ return writeImage(UI.getCurrentOrThrow(), imageUrl);
+ }
+
+ /**
+ * Writes an image from the given URL to the clipboard using the specified
+ * UI.
+ *
+ * @param ui
+ * the UI to use
+ * @param imageUrl
+ * the URL of the image to copy
+ * @return a {@link PendingJavaScriptResult} for the clipboard operation
+ */
+ static PendingJavaScriptResult writeImage(UI ui, String imageUrl) {
+ return ui.getPage().executeJs(
+ "return window.Vaadin.Flow.clipboard.writeImage($0)", imageUrl);
+ }
+
+ /**
+ * Writes an image from the given {@link StreamResource} to the clipboard.
+ *
+ * The stream resource is registered in the session and the browser fetches
+ * it client-side.
+ *
+ * @param resource
+ * the stream resource providing the image data
+ * @return a {@link PendingJavaScriptResult} for the clipboard operation
+ */
+ static PendingJavaScriptResult writeImage(StreamResource resource) {
+ return writeImage(UI.getCurrentOrThrow(), resource);
+ }
+
+ /**
+ * Writes an image from the given {@link StreamResource} to the clipboard
+ * using the specified UI.
+ *
+ * @param ui
+ * the UI to use
+ * @param resource
+ * the stream resource providing the image data
+ * @return a {@link PendingJavaScriptResult} for the clipboard operation
+ */
+ static PendingJavaScriptResult writeImage(UI ui, StreamResource resource) {
+ Objects.requireNonNull(resource, "Resource must not be null");
+
+ StreamRegistration registration = ui.getSession().getResourceRegistry()
+ .registerResource(resource);
+
+ String url = registration.getResourceUri().toString();
+ return writeImage(ui, url);
+ }
+
+ // --- Event listeners ---
+
+ /**
+ * Adds a listener for paste events on the given component.
+ *
+ * The listener receives text, HTML, and file data from the paste event.
+ * Pasted files are transferred to the server via the upload mechanism.
+ *
+ * @param target
+ * the component to listen for paste events on
+ * @param listener
+ * the paste event listener
+ * @return a registration for removing the listener
+ */
+ static Registration addPasteListener(Component target,
+ SerializableConsumer
+ * Allows updating the text that will be copied and removing the click handler
+ * when no longer needed.
+ */
+public class ClipboardCopy implements Registration, Serializable {
+
+ static final String CLIPBOARD_TEXT_PROPERTY = "__clipboardText";
+
+ private final Element triggerElement;
+ private final Registration cleanupRegistration;
+
+ /**
+ * Creates a new clipboard copy handle.
+ *
+ * @param triggerElement
+ * the element that has the click handler installed
+ * @param cleanupRegistration
+ * the registration to call when removing the click handler
+ */
+ ClipboardCopy(Element triggerElement, Registration cleanupRegistration) {
+ this.triggerElement = triggerElement;
+ this.cleanupRegistration = cleanupRegistration;
+ }
+
+ /**
+ * Updates the text that will be copied to the clipboard when the trigger
+ * element is clicked.
+ *
+ * The new value is pushed to the client-side so the copy operation can
+ * execute without a server round-trip.
+ *
+ * @param text
+ * the new text to copy, or an empty string if {@code null}
+ */
+ public void setValue(String text) {
+ triggerElement.setProperty(CLIPBOARD_TEXT_PROPERTY,
+ text != null ? text : "");
+ }
+
+ /**
+ * Removes the client-side click handler and cleans up associated resources.
+ */
+ @Override
+ public void remove() {
+ cleanupRegistration.remove();
+ }
+}
diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java
new file mode 100644
index 00000000000..8cc950c2dba
--- /dev/null
+++ b/flow-server/src/main/java/com/vaadin/flow/component/page/ClipboardEvent.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.page;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a clipboard event (paste, copy, or cut) received from the browser.
+ *
+ * A clipboard event may contain text, HTML content, files, or a combination of
+ * these.
+ */
+public class ClipboardEvent implements Serializable {
+
+ private final String type;
+ private final String text;
+ private final String html;
+ private final List
+ * Contains the file name, MIME type, size, and raw byte data of the pasted
+ * file.
+ */
+public class ClipboardFile implements Serializable {
+
+ private final String fileName;
+ private final String contentType;
+ private final long size;
+ private final byte[] data;
+
+ /**
+ * Creates a new clipboard file.
+ *
+ * @param fileName
+ * the name of the file
+ * @param contentType
+ * the MIME type of the file
+ * @param size
+ * the size of the file in bytes
+ * @param data
+ * the raw byte data of the file
+ */
+ ClipboardFile(String fileName, String contentType, long size, byte[] data) {
+ this.fileName = fileName;
+ this.contentType = contentType;
+ this.size = size;
+ this.data = data;
+ }
+
+ /**
+ * Gets the name of the file.
+ *
+ * @return the file name
+ */
+ public String getName() {
+ return fileName;
+ }
+
+ /**
+ * Gets the MIME type of the file.
+ *
+ * @return the MIME type, e.g. "image/png"
+ */
+ public String getMimeType() {
+ return contentType;
+ }
+
+ /**
+ * Gets the size of the file in bytes.
+ *
+ * @return the file size
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * Gets the raw byte data of the file.
+ *
+ * @return the file data
+ */
+ public byte[] getData() {
+ return data;
+ }
+}
diff --git a/flow-server/src/main/resources/META-INF/frontend/clipboard.js b/flow-server/src/main/resources/META-INF/frontend/clipboard.js
new file mode 100644
index 00000000000..d238dac9ce6
--- /dev/null
+++ b/flow-server/src/main/resources/META-INF/frontend/clipboard.js
@@ -0,0 +1,146 @@
+/*
+ * 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.
+ */
+
+window.Vaadin = window.Vaadin || {};
+window.Vaadin.Flow = window.Vaadin.Flow || {};
+window.Vaadin.Flow.clipboard = {
+ setupCopyOnClick: function (element) {
+ if (!element.__clipboardClickHandler) {
+ element.__clipboardClickHandler = function () {
+ var text = element.__clipboardText || '';
+ navigator.clipboard.writeText(text);
+ };
+ element.addEventListener('click', element.__clipboardClickHandler);
+ }
+ },
+
+ setupCopyOnClickWithCallbacks: function (element, onSuccess, onError) {
+ if (element.__clipboardClickHandler) {
+ element.removeEventListener('click', element.__clipboardClickHandler);
+ }
+ element.__clipboardClickHandler = function () {
+ var text = element.__clipboardText || '';
+ navigator.clipboard.writeText(text).then(
+ function () {
+ onSuccess();
+ },
+ function () {
+ onError();
+ }
+ );
+ };
+ element.addEventListener('click', element.__clipboardClickHandler);
+ },
+
+ setupCopyOnClickFromSource: function (element, sourceElement) {
+ element.__clipboardSourceElement = sourceElement;
+ if (element.__clipboardClickHandler) {
+ element.removeEventListener('click', element.__clipboardClickHandler);
+ }
+ element.__clipboardClickHandler = function () {
+ var src = element.__clipboardSourceElement;
+ var text = src ? (src.value !== undefined && src.value !== null ? String(src.value) : src.textContent || '') : '';
+ navigator.clipboard.writeText(text);
+ };
+ element.addEventListener('click', element.__clipboardClickHandler);
+ },
+
+ setupCopyImageOnClick: function (element, imageElement) {
+ element.__clipboardImageElement = imageElement;
+ if (element.__clipboardClickHandler) {
+ element.removeEventListener('click', element.__clipboardClickHandler);
+ }
+ element.__clipboardClickHandler = function () {
+ var img = element.__clipboardImageElement;
+ if (!img || !img.src) return;
+ fetch(img.src)
+ .then(function (r) {
+ return r.blob();
+ })
+ .then(function (blob) {
+ var type = blob.type || 'image/png';
+ var pngBlob = blob;
+ if (type !== 'image/png') {
+ pngBlob = new Blob([blob], { type: 'image/png' });
+ }
+ return navigator.clipboard.write([new ClipboardItem(Object.fromEntries([[pngBlob.type, pngBlob]]))]);
+ });
+ };
+ element.addEventListener('click', element.__clipboardClickHandler);
+ },
+
+ cleanupCopyOnClick: function (element) {
+ if (element.__clipboardClickHandler) {
+ element.removeEventListener('click', element.__clipboardClickHandler);
+ delete element.__clipboardClickHandler;
+ delete element.__clipboardText;
+ delete element.__clipboardSourceElement;
+ delete element.__clipboardImageElement;
+ }
+ },
+
+ writeText: function (text) {
+ return navigator.clipboard.writeText(text);
+ },
+
+ readText: function () {
+ return navigator.clipboard.readText();
+ },
+
+ writeImage: function (url) {
+ return fetch(url)
+ .then(function (r) {
+ return r.blob();
+ })
+ .then(function (blob) {
+ return navigator.clipboard.write([new ClipboardItem(Object.fromEntries([[blob.type, blob]]))]);
+ });
+ },
+
+ setupPasteListener: function (element, channel) {
+ if (element.__clipboardPasteHandler) {
+ element.removeEventListener('paste', element.__clipboardPasteHandler);
+ }
+ element.__clipboardPasteHandler = async function (e) {
+ e.preventDefault();
+ var text = e.clipboardData.getData('text/plain') || null;
+ var html = e.clipboardData.getData('text/html') || null;
+ var files = [];
+ for (var i = 0; i < e.clipboardData.items.length; i++) {
+ if (e.clipboardData.items[i].kind === 'file') {
+ files.push(e.clipboardData.items[i].getAsFile());
+ }
+ }
+ var uploadUrl = element.getAttribute('__clipboard-paste-upload');
+ if (uploadUrl && files.length > 0) {
+ for (var i = 0; i < files.length; i++) {
+ var fd = new FormData();
+ fd.append('file', files[i], files[i].name || 'pasted-file');
+ await fetch(uploadUrl, { method: 'POST', body: fd });
+ }
+ }
+ channel(text, html, files.length);
+ };
+ element.addEventListener('paste', element.__clipboardPasteHandler);
+ },
+
+ cleanupPasteListener: function (element) {
+ if (element.__clipboardPasteHandler) {
+ element.removeEventListener('paste', element.__clipboardPasteHandler);
+ delete element.__clipboardPasteHandler;
+ }
+ }
+};
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java
new file mode 100644
index 00000000000..922d4baa03b
--- /dev/null
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardCopyTest.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.page;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.vaadin.flow.dom.Element;
+import com.vaadin.flow.shared.Registration;
+
+public class ClipboardCopyTest {
+
+ @Test
+ public void setValue_updatesProperty() {
+ Element element = new Element("button");
+ ClipboardCopy copy = new ClipboardCopy(element,
+ Registration.once(() -> {
+ }));
+
+ copy.setValue("hello");
+ Assert.assertEquals("hello",
+ element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY));
+
+ copy.setValue("world");
+ Assert.assertEquals("world",
+ element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY));
+ }
+
+ @Test
+ public void setValueNull_setsEmptyString() {
+ Element element = new Element("button");
+ ClipboardCopy copy = new ClipboardCopy(element,
+ Registration.once(() -> {
+ }));
+
+ copy.setValue(null);
+ Assert.assertEquals("",
+ element.getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY));
+ }
+
+ @Test
+ public void remove_callsCleanupRegistration() {
+ AtomicBoolean cleaned = new AtomicBoolean(false);
+ Element element = new Element("button");
+ ClipboardCopy copy = new ClipboardCopy(element,
+ Registration.once(() -> cleaned.set(true)));
+
+ copy.remove();
+ Assert.assertTrue(cleaned.get());
+ }
+
+ @Test
+ public void remove_calledTwice_cleanupRunsOnlyOnce() {
+ int[] count = { 0 };
+ Element element = new Element("button");
+ ClipboardCopy copy = new ClipboardCopy(element,
+ Registration.once(() -> count[0]++));
+
+ copy.remove();
+ copy.remove();
+ Assert.assertEquals(1, count[0]);
+ }
+}
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java
new file mode 100644
index 00000000000..6314992097d
--- /dev/null
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardEventTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.page;
+
+import java.util.List;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ClipboardEventTest {
+
+ @Test
+ public void textEvent_hasCorrectValues() {
+ ClipboardEvent event = new ClipboardEvent("paste", "hello", null,
+ List.of());
+
+ Assert.assertEquals("paste", event.getType());
+ Assert.assertEquals("hello", event.getText());
+ Assert.assertNull(event.getHtml());
+ Assert.assertTrue(event.hasText());
+ Assert.assertFalse(event.hasHtml());
+ Assert.assertFalse(event.hasFiles());
+ Assert.assertTrue(event.getFiles().isEmpty());
+ }
+
+ @Test
+ public void htmlEvent_hasCorrectValues() {
+ ClipboardEvent event = new ClipboardEvent("paste", "plain",
+ "bold", List.of());
+
+ Assert.assertTrue(event.hasText());
+ Assert.assertTrue(event.hasHtml());
+ Assert.assertEquals("bold", event.getHtml());
+ }
+
+ @Test
+ public void emptyTextAndHtml_hasTextReturnsFalse() {
+ ClipboardEvent event = new ClipboardEvent("paste", "", "", List.of());
+
+ Assert.assertFalse(event.hasText());
+ Assert.assertFalse(event.hasHtml());
+ }
+
+ @Test
+ public void nullTextAndHtml_hasTextReturnsFalse() {
+ ClipboardEvent event = new ClipboardEvent("paste", null, null,
+ List.of());
+
+ Assert.assertFalse(event.hasText());
+ Assert.assertFalse(event.hasHtml());
+ }
+
+ @Test
+ public void eventWithFiles_hasFilesReturnsTrue() {
+ ClipboardFile file = new ClipboardFile("test.png", "image/png", 100,
+ new byte[100]);
+ ClipboardEvent event = new ClipboardEvent("paste", null, null,
+ List.of(file));
+
+ Assert.assertTrue(event.hasFiles());
+ Assert.assertEquals(1, event.getFiles().size());
+ Assert.assertEquals("test.png", event.getFiles().get(0).getName());
+ }
+
+ @Test
+ public void filesListIsUnmodifiable() {
+ ClipboardEvent event = new ClipboardEvent("paste", null, null, List
+ .of(new ClipboardFile("f.txt", "text/plain", 5, new byte[5])));
+
+ Assert.assertThrows(UnsupportedOperationException.class, () -> event
+ .getFiles()
+ .add(new ClipboardFile("x.txt", "text/plain", 1, new byte[1])));
+ }
+
+ @Test
+ public void nullFilesList_becomesEmptyList() {
+ ClipboardEvent event = new ClipboardEvent("paste", "text", null, null);
+
+ Assert.assertNotNull(event.getFiles());
+ Assert.assertTrue(event.getFiles().isEmpty());
+ Assert.assertFalse(event.hasFiles());
+ }
+
+ @Test
+ public void copyEvent_hasCorrectType() {
+ ClipboardEvent event = new ClipboardEvent("copy", "copied text", null,
+ List.of());
+ Assert.assertEquals("copy", event.getType());
+ }
+
+ @Test
+ public void cutEvent_hasCorrectType() {
+ ClipboardEvent event = new ClipboardEvent("cut", "cut text", null,
+ List.of());
+ Assert.assertEquals("cut", event.getType());
+ }
+
+ @Test
+ public void clipboardFile_hasCorrectValues() {
+ byte[] data = { 1, 2, 3, 4, 5 };
+ ClipboardFile file = new ClipboardFile("photo.jpg", "image/jpeg", 5,
+ data);
+
+ Assert.assertEquals("photo.jpg", file.getName());
+ Assert.assertEquals("image/jpeg", file.getMimeType());
+ Assert.assertEquals(5, file.getSize());
+ Assert.assertArrayEquals(data, file.getData());
+ }
+}
diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java
new file mode 100644
index 00000000000..987b9750bb0
--- /dev/null
+++ b/flow-server/src/test/java/com/vaadin/flow/component/page/ClipboardTest.java
@@ -0,0 +1,364 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.page;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.Tag;
+import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
+import com.vaadin.flow.shared.Registration;
+import com.vaadin.tests.util.MockUI;
+
+public class ClipboardTest {
+
+ @Tag("button")
+ private static class TestButton extends Component {
+ }
+
+ @Tag("input")
+ private static class TestTextField extends Component {
+ }
+
+ @Tag("div")
+ private static class TestDiv extends Component {
+ }
+
+ private MockUI ui;
+
+ @Before
+ public void setUp() {
+ ui = new MockUI();
+ }
+
+ @After
+ public void tearDown() {
+ ui = null;
+ }
+
+ // --- copyOnClick(Component, String) ---
+
+ @Test
+ public void copyOnClick_setsPropertyAndInstallsHandler() {
+ TestButton button = new TestButton();
+ ui.add(button);
+
+ ClipboardCopy handle = Clipboard.copyOnClick(button, "test text");
+
+ Assert.assertNotNull(handle);
+ Assert.assertEquals("test text", button.getElement()
+ .getProperty(ClipboardCopy.CLIPBOARD_TEXT_PROPERTY));
+
+ List