This is an automated email from the ASF dual-hosted git repository.

reiern70 pushed a commit to branch ajax-veil
in repository https://gitbox.apache.org/repos/asf/wicket.git

commit ea728f91b129831ebdb3d6dc6773e2e6ea6f4b47
Author: reiern70 <[email protected]>
AuthorDate: Thu Oct 1 15:16:23 2026 -0500

    Add Ajax veil behaviors that block the page or a component during a request
    
    Wicket had no built-in way to stop the user from interacting with a
    page while an Ajax request was running. An activity indicator shows
    that something is happening, but a second click on a slow button still
    sent the request again, and a click elsewhere could act on markup the
    pending response was about to replace. AjaxDisableComponentListener
    covers only the component that fired the request, so applications
    wrote their own veil on top of the global Ajax topics. See GitHub
    issue #1631.
    
    wicket-extensions now has a package org.apache.wicket.extensions.ajax.veil
    with two behaviors sharing the base class AbstractVeilBehavior:
    
    - PageVeilBehavior, added to a page, veils the whole page during every
      Ajax request fired from it. Adding it to anything but a Page throws
      IllegalArgumentException.
    - LocalVeilBehavior, added to any component, veils only that component,
      and only during requests fired from it or from a component nested in
      it; such a request does not veil the page. Local veils nest: the
      innermost one takes the request, each with its own timings.
    
    The veil goes up on /ajax/call/beforeSend and comes down on
    /ajax/call/done, so it also lifts on failure. It is transparent and
    swallows clicks, so a fast request does not make the page flash. After
    300 ms it gets the class wicket-veil-busy, which dims the region and
    shows a CSS spinner that then stays for at least 500 ms so a response
    arriving just after it does not make it flicker. Both timings are
    settable per behavior. Concurrent requests share one veil, and the
    keyboard is not intercepted.
    
    A request opts out with PageVeilBehavior.noVeil(attributes), which adds
    the extra parameter wicket_nb; the page veil and every local veil leave
    it alone. Background requests (timers, lazy loading panels) are veiled
    unless they opt out.
    
    A component updated by a WebSocket push is recomputed with no Ajax
    request the browser could notice, so the server raises its veil:
    LocalVeilBehavior.getVeilMessage() is a text message to send through
    the page's WebSocket connection when the work starts, unveil(handler)
    lowers the veil with the pushed update, and getUnveilMessage() lowers
    it when the work fails. wicket-extensions does not depend on the
    WebSocket module; the client listens on /websocket/message.
    
    wicket-veil.js subscribes to the global Ajax topics, so it works with
    both the jQuery-based and the plain JavaScript Ajax engine, and nothing
    changes in wicket-core.
    
    This is new API only: nothing is changed or removed, and an application
    that does not add one of the behaviors sees no difference, so no
    migration is needed.
    
    Also adds the ajax/veil and websockets/veil examples, a user guide
    section in the Ajax chapter, Java tests for the behaviors, QUnit tests
    for Wicket.Veil run against both engines, and Selenium tests driving
    ajax/veil in headless Chrome. The Selenium tests need Chrome and run
    only with -Dwicket.selenium=true; JAVASCRIPTTESTING.md says how.
---
 JAVASCRIPTTESTING.md                               |  22 ++
 pom.xml                                            |   7 +
 testing/wicket-js-tests/Gruntfile.js               |  12 +-
 wicket-examples/pom.xml                            |   4 +
 .../examples/ajax/builtin/AjaxApplication.java     |   1 +
 .../wicket/examples/ajax/builtin/VeilPage.css      |  30 ++
 .../wicket/examples/ajax/builtin/VeilPage.html     |  48 +++
 .../wicket/examples/ajax/builtin/VeilPage.java     | 140 ++++++++
 .../examples/ajax/builtin/VeilPage.properties      |  17 +
 .../examples/websocket/JSR356Application.java      |   1 +
 .../examples/websocket/WebSocketVeilDemoPage.css   |  27 ++
 .../examples/websocket/WebSocketVeilDemoPage.html  |  18 +
 .../examples/websocket/WebSocketVeilDemoPage.java  | 250 +++++++++++++
 .../websocket/WebSocketVeilDemoPage.properties     |  17 +
 .../ajax/builtin/VeilPageSeleniumTest.java         | 398 +++++++++++++++++++++
 wicket-extensions/pom.xml                          |   1 +
 wicket-extensions/src/main/java/module-info.java   |   2 +
 .../extensions/ajax/veil/AbstractVeilBehavior.java | 150 ++++++++
 .../extensions/ajax/veil/LocalVeilBehavior.java    | 157 ++++++++
 .../extensions/ajax/veil/PageVeilBehavior.java     |  86 +++++
 .../wicket/extensions/ajax/veil/wicket-veil.css    |  61 ++++
 .../wicket/extensions/ajax/veil/wicket-veil.js     | 324 +++++++++++++++++
 .../extensions/ajax/veil/VeilBehaviorTest.java     | 249 +++++++++++++
 wicket-extensions/src/test/js/veil-test.js         | 388 ++++++++++++++++++++
 wicket-extensions/src/test/js/veil.html            |  65 ++++
 .../src/main/asciidoc/ajax/ajax_12.adoc            | 104 ++++++
 wicket-user-guide/src/main/asciidoc/single.adoc    |   4 +
 27 files changed, 2579 insertions(+), 4 deletions(-)

diff --git a/JAVASCRIPTTESTING.md b/JAVASCRIPTTESTING.md
index cf0fac9b82..19edaa1801 100644
--- a/JAVASCRIPTTESTING.md
+++ b/JAVASCRIPTTESTING.md
@@ -88,8 +88,30 @@ engine/jQuery version combination by hand in a real browser.
   suite (`jshint:testsJs` / `qunit:all` / `qunit:vanilla` targets in
   `testing/wicket-js-tests/Gruntfile.js`), covering both Ajax engines plus 
`Wicket.DOM`,
   `Wicket.Event`, `Wicket.Form`, `Wicket.Head`, and the channel manager.
+- `wicket-extensions/src/test/js/*-test.js` - QUnit tests for 
`wicket-extensions`' own scripts
+  (`Wicket.Palette`, `Wicket.trapFocus`, `Wicket.Veil`), each with its own 
page next to it
+  (`palette.html`, `trap-focus.html`, `veil.html`), served on 
`http://localhost:38888` and run
+  against both engines.
 - `jshint:core` / `jshint:extensions` / `jshint:nativeWebSocket` - lint-only 
coverage
   (ES6+, no test runtime) for the rest of the framework's JS: 
`wicket-extensions`
   Ajax components (autocomplete, palette, upload progress bar, ajax download, 
trap focus),
   the dev debug bar, and native WebSocket support.
 - `jshint:gymTestsJs` - the `wicket-examples` JS tests.
+
+## Browser tests with Selenium
+
+The QUnit tests exercise the scripts in isolation, with the Ajax topics 
published by hand.
+Behaviour that only shows in a real page - a veil that swallows clicks, 
timings measured
+against real requests - is tested with Selenium in `wicket-examples`, against 
the examples
+webapp started in Jetty by `JettyTestCaseDecorator`. `VeilPageSeleniumTest` 
drives
+`ajax/veil` in headless Chrome, once per Ajax engine.
+
+They need Chrome, so they do not run by default. Enable them with a system 
property:
+
+```bash
+mvn install -DskipTests -Pfast
+mvn verify -pl wicket-examples -Dwicket.selenium=true 
-Dtest=VeilPageSeleniumTest
+```
+
+Selenium Manager, which ships with Selenium, finds a matching ChromeDriver, 
and downloads
+Chrome for Testing too when no Chrome is installed, so the first run needs 
network access.
diff --git a/pom.xml b/pom.xml
index 9f62a1ef79..10ff50ba8b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -195,6 +195,7 @@
                <mockito.version>5.24.0</mockito.version>
                <objenesis.version>3.6</objenesis.version>
                <openjson.version>1.0.13</openjson.version>
+               <selenium.version>4.35.0</selenium.version>
                <slf4j.version>2.0.20</slf4j.version>
                <spring.version>7.0.9</spring.version>
                <wagon-ssh-external.version>3.5.3</wagon-ssh-external.version>
@@ -726,6 +727,12 @@
                                <version>${mockito.version}</version>
                                <scope>test</scope>
                        </dependency>
+                       <dependency>
+                               <groupId>org.seleniumhq.selenium</groupId>
+                               <artifactId>selenium-java</artifactId>
+                               <version>${selenium.version}</version>
+                               <scope>test</scope>
+                       </dependency>
                        <dependency>
                                <groupId>org.springframework</groupId>
                                <artifactId>spring-test</artifactId>
diff --git a/testing/wicket-js-tests/Gruntfile.js 
b/testing/wicket-js-tests/Gruntfile.js
index 1a6893b9da..848722e083 100644
--- a/testing/wicket-js-tests/Gruntfile.js
+++ b/testing/wicket-js-tests/Gruntfile.js
@@ -33,7 +33,8 @@ module.exports = function(grunt) {
                        
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/form/palette/palette.js",
                        
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/markup/html/autocomplete/wicket-autocomplete.js",
                        
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/markup/html/modal/res/modal.js",
-                       
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/repeater/data/table/filter/wicket-filterform.js"
+                       
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/repeater/data/table/filter/wicket-filterform.js",
+                       
"../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js"
                ],
                nativeWebSocketJs = [
                        
"../../wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/res/js/wicket-websocket-jquery.js"
@@ -49,7 +50,8 @@ module.exports = function(grunt) {
                ],
                extensionsTestsJs = [
                        "../../wicket-extensions/src/test/js/palette-test.js",
-                       "../../wicket-extensions/src/test/js/trapfocus-test.js"
+                       "../../wicket-extensions/src/test/js/trapfocus-test.js",
+                       "../../wicket-extensions/src/test/js/veil-test.js"
                ],
                gymTestsJs = [
                        
"../../wicket-examples/src/main/webapp/js-test/tests/ajax/form.js",
@@ -115,7 +117,8 @@ module.exports = function(grunt) {
                                        urls: [
                                                
'http://localhost:38887/test/js/all.html?4.0.0',
                                                
'http://localhost:38888/wicket-extensions/src/test/js/palette.html?4.0.0',
-                                               
'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?4.0.0'
+                                               
'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?4.0.0',
+                                               
'http://localhost:38888/wicket-extensions/src/test/js/veil.html?4.0.0'
                                        ],
                                        puppeteer: {
                                                headless: true,
@@ -133,7 +136,8 @@ module.exports = function(grunt) {
                                        urls: [
                                                
'http://localhost:38887/test/js/all.html?vanilla',
                                                
'http://localhost:38888/wicket-extensions/src/test/js/palette.html?vanilla',
-                                               
'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?vanilla'
+                                               
'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?vanilla',
+                                               
'http://localhost:38888/wicket-extensions/src/test/js/veil.html?vanilla'
                                        ],
                                        puppeteer: {
                                                headless: true,
diff --git a/wicket-examples/pom.xml b/wicket-examples/pom.xml
index 4463cdb7ff..cfc9bcf466 100644
--- a/wicket-examples/pom.xml
+++ b/wicket-examples/pom.xml
@@ -185,6 +185,10 @@
                        <groupId>org.httpunit</groupId>
                        <artifactId>httpunit</artifactId>
                </dependency>
+               <dependency>
+                       <groupId>org.seleniumhq.selenium</groupId>
+                       <artifactId>selenium-java</artifactId>
+               </dependency>
        </dependencies>
        <build>
                <resources>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java
index 68e670ae15..075d3c1c65 100644
--- 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java
@@ -68,6 +68,7 @@ public class AjaxApplication extends WicketExampleApplication
                mountPage("todo-list", TodoList.class);
                mountPage("world-clock", WorldClockPage.class);
                mountPage("upload", FileUploadPage.class);
+               mountPage("veil", VeilPage.class);
                mountPage("download", AjaxDownloadPage.class);
 
                mountResource("dynamic-text-file", 
AjaxDownloadPage.DynamicTextFileResource.instance);
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css
new file mode 100644
index 0000000000..54f8963082
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+.veil-panel {
+       padding: 1em;
+       border: 1px solid #ccc;
+}
+
+.veil-outer {
+       max-width: 40em;
+}
+
+.veil-inner {
+       margin-top: 1em;
+       background: #f7f7f7;
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html
new file mode 100644
index 0000000000..0509383d71
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<wicket:extend xmlns:wicket="http://wicket.apache.org";>
+
+<h2>Page veil</h2>
+
+<p>
+       Page clicks: <span wicket:id="pageCounter" class="page-counter"></span>
+</p>
+
+<ul>
+       <li><a href="#" wicket:id="slow">Slow request</a>: takes a second, so 
the spinner shows.</li>
+       <li><a href="#" wicket:id="medium">Medium request</a>: takes 400 ms, so 
the spinner shows, and stays for its minimum time of 500 ms.</li>
+       <li><a href="#" wicket:id="fast">Fast request</a>: takes 100 ms, the 
page is veiled but no spinner shows.</li>
+       <li><a href="#" wicket:id="unveiled">Unveiled request</a>: takes a 
second, but opts out of the veil, so the page stays usable.</li>
+</ul>
+
+<h2>Nested local veils</h2>
+
+<div wicket:id="outer" class="veil-panel veil-outer">
+       <p>
+               This panel has its own veil with the default timings: the 
spinner shows after 300 ms and
+               stays for at least 500 ms. Requests fired from inside it veil 
only the panel, so the rest
+               of the page stays usable.
+       </p>
+       <p>
+               Outer panel clicks: <span wicket:id="outerCounter" 
class="outer-counter"></span>
+       </p>
+       <p>
+               <a href="#" wicket:id="outerSlow">Slow request from the outer 
panel</a>: takes two seconds and veils the whole outer panel, inner panel 
included.
+       </p>
+
+       <div wicket:id="inner" class="veil-panel veil-inner">
+               <p>
+                       This nested panel has a veil of its own, configured with
+                       <code>setSpinnerDelay(100 ms)</code> and 
<code>setMinimumSpinnerTime(1 s)</code>.
+                       Requests fired from inside it veil only this panel: the 
innermost veil takes the request.
+               </p>
+               <p>
+                       Inner panel clicks: <span wicket:id="innerCounter" 
class="inner-counter"></span>
+               </p>
+               <ul>
+                       <li><a href="#" wicket:id="innerSlow">Slow request from 
the inner panel</a>: takes two seconds.</li>
+                       <li><a href="#" wicket:id="innerShort">Short request 
from the inner panel</a>: takes 200 ms, but the spinner shows after 100 ms and 
stays for a second.</li>
+               </ul>
+       </div>
+</div>
+
+</wicket:extend>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java
new file mode 100644
index 0000000000..1d1d279d71
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.examples.ajax.builtin;
+
+import java.time.Duration;
+
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.extensions.ajax.veil.LocalVeilBehavior;
+import org.apache.wicket.extensions.ajax.veil.PageVeilBehavior;
+import org.apache.wicket.markup.head.CssHeaderItem;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.request.resource.CssResourceReference;
+
+/**
+ * Demonstrates {@link PageVeilBehavior} and {@link LocalVeilBehavior}, 
including nested local
+ * veils with their own timings.
+ */
+public class VeilPage extends BasePage
+{
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * Constructor.
+        */
+       public VeilPage()
+       {
+               add(new PageVeilBehavior());
+
+               Label pageCounter = counter(this, "pageCounter");
+               add(new SleepingLink("slow", Duration.ofSeconds(1), 
pageCounter, false));
+               add(new SleepingLink("medium", Duration.ofMillis(400), 
pageCounter, false));
+               add(new SleepingLink("fast", Duration.ofMillis(100), 
pageCounter, false));
+               add(new SleepingLink("unveiled", Duration.ofSeconds(1), 
pageCounter, true));
+
+               WebMarkupContainer outer = new WebMarkupContainer("outer");
+               outer.add(new LocalVeilBehavior());
+               add(outer);
+
+               Label outerCounter = counter(outer, "outerCounter");
+               outer.add(new SleepingLink("outerSlow", Duration.ofSeconds(2), 
outerCounter, false));
+
+               WebMarkupContainer inner = new WebMarkupContainer("inner");
+               inner.add(new 
LocalVeilBehavior().setSpinnerDelay(Duration.ofMillis(100))
+                       .setMinimumSpinnerTime(Duration.ofSeconds(1)));
+               outer.add(inner);
+
+               Label innerCounter = counter(inner, "innerCounter");
+               inner.add(new SleepingLink("innerSlow", Duration.ofSeconds(2), 
innerCounter, false));
+               inner.add(new SleepingLink("innerShort", 
Duration.ofMillis(200), innerCounter, false));
+       }
+
+       @Override
+       public void renderHead(IHeaderResponse response)
+       {
+               super.renderHead(response);
+
+               response.render(CssHeaderItem.forReference(new 
CssResourceReference(VeilPage.class,
+                       "VeilPage.css")));
+       }
+
+       private static Label counter(MarkupContainer parent, String id)
+       {
+               Label counter = new Label(id, Model.of(0));
+               counter.setOutputMarkupId(true);
+               parent.add(counter);
+               return counter;
+       }
+
+       private static class SleepingLink extends AjaxLink<Void>
+       {
+               private static final long serialVersionUID = 1L;
+
+               private final Duration duration;
+
+               private final Label counter;
+
+               private final boolean noVeil;
+
+               SleepingLink(String id, Duration duration, Label counter, 
boolean noVeil)
+               {
+                       super(id);
+                       this.duration = duration;
+                       this.counter = counter;
+                       this.noVeil = noVeil;
+               }
+
+               @Override
+               protected void updateAjaxAttributes(AjaxRequestAttributes 
attributes)
+               {
+                       super.updateAjaxAttributes(attributes);
+                       if (noVeil)
+                       {
+                               PageVeilBehavior.noVeil(attributes);
+                       }
+               }
+
+               @Override
+               @SuppressWarnings("unchecked")
+               public void onClick(AjaxRequestTarget target)
+               {
+                       sleep(duration);
+                       IModel<Integer> clicks = 
(IModel<Integer>)counter.getDefaultModel();
+                       clicks.setObject(clicks.getObject() + 1);
+                       target.add(counter);
+               }
+       }
+
+       private static void sleep(Duration duration)
+       {
+               try
+               {
+                       Thread.sleep(duration.toMillis());
+               }
+               catch (InterruptedException e)
+               {
+                       Thread.currentThread().interrupt();
+               }
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties
new file mode 100644
index 0000000000..51fd11848e
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties
@@ -0,0 +1,17 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You 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.
+title=Veil Example
+description=blocks the page, or a part of it, while an Ajax request is running.
+explanation=<p> PageVeilBehavior puts a transparent veil over the page as soon 
as an Ajax request starts, so nothing can be clicked until it is done. A 
spinner shows only if the request takes longer than 300 ms, and then stays for 
at least 500 ms. LocalVeilBehavior does the same for a single component, for 
the requests fired from inside it. Local veils can be nested, the innermost one 
taking the request, and each can have its own timings. A request opts out with 
PageVeilBehavior.noVeil(a [...]
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java
index 660a91a17c..0b089149e7 100644
--- 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java
@@ -61,6 +61,7 @@ public class JSR356Application extends 
WicketExampleApplication
                mountPage("/push", WebSocketPushUpdateProgressDemoPage.class);
                mountPage("/resource", WebSocketResourceDemoPage.class);
                mountPage("/resource-multi-tab", 
WebSocketMultiTabResourceDemoPage.class);
+               mountPage("/veil", WebSocketVeilDemoPage.class);
 
                getSharedResources().add(ChartWebSocketResource.NAME, new 
ChartWebSocketResource());
 
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css
new file mode 100644
index 0000000000..05315f90d9
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+.push-counter-panel {
+       max-width: 30em;
+       padding: 1em;
+       border: 1px solid #ccc;
+}
+
+.push-counter {
+       font-size: 2em;
+       font-weight: bold;
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html
new file mode 100644
index 0000000000..77e6994e59
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"; 
xmlns:wicket="http://wicket.apache.org";>
+<body>
+<wicket:extend>
+       <p>
+               <a href="#" wicket:id="start">Start pushes</a> |
+               <a href="#" wicket:id="stop">Stop</a>
+       </p>
+
+       <div wicket:id="counterPanel" class="push-counter-panel">
+               <p>
+                       Pushed counter: <span wicket:id="counter" 
class="push-counter"></span>
+               </p>
+               <p wicket:id="lastWork" class="push-last-work"></p>
+       </div>
+</wicket:extend>
+</body>
+</html>
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java
new file mode 100644
index 0000000000..3c9b001fc0
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.examples.websocket;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.wicket.Application;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.event.IEvent;
+import org.apache.wicket.examples.WicketExamplePage;
+import org.apache.wicket.extensions.ajax.veil.LocalVeilBehavior;
+import org.apache.wicket.markup.head.CssHeaderItem;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.protocol.ws.WebSocketSettings;
+import org.apache.wicket.protocol.ws.api.IWebSocketConnection;
+import org.apache.wicket.protocol.ws.api.WebSocketBehavior;
+import org.apache.wicket.protocol.ws.api.event.WebSocketPushPayload;
+import org.apache.wicket.protocol.ws.api.message.IWebSocketPushMessage;
+import org.apache.wicket.protocol.ws.api.registry.PageIdKey;
+import org.apache.wicket.request.resource.CssResourceReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Veils a panel while the server recomputes it, then pushes the result 
through a WebSocket
+ * connection, see {@link LocalVeilBehavior#getVeilMessage()}.
+ */
+public class WebSocketVeilDemoPage extends WicketExamplePage
+{
+       private static final long serialVersionUID = 1L;
+
+       private static final Logger LOGGER = 
LoggerFactory.getLogger(WebSocketVeilDemoPage.class);
+
+       private static final int ROUNDS = 10;
+
+       /** The running tasks, by session and page, so a page runs at most one. 
*/
+       private static final Map<String, PushTask> TASKS = new 
ConcurrentHashMap<>();
+
+       private final WebMarkupContainer counterPanel;
+
+       private final LocalVeilBehavior veil = new LocalVeilBehavior();
+
+       private final Label counter;
+
+       private final Label lastWork;
+
+       /**
+        * Constructor.
+        */
+       public WebSocketVeilDemoPage()
+       {
+               add(new WebSocketBehavior()
+               {
+                       private static final long serialVersionUID = 1L;
+               });
+
+               counterPanel = new WebMarkupContainer("counterPanel");
+               counterPanel.add(veil);
+               add(counterPanel);
+
+               counter = new Label("counter", Model.of(0));
+               counterPanel.add(counter);
+               lastWork = new Label("lastWork", Model.of("No update pushed 
yet."));
+               counterPanel.add(lastWork);
+
+               add(new AjaxLink<Void>("start")
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void onClick(AjaxRequestTarget target)
+                       {
+                               startPushes();
+                       }
+               });
+
+               add(new AjaxLink<Void>("stop")
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void onClick(AjaxRequestTarget target)
+                       {
+                               PushTask task = TASKS.get(taskKey());
+                               if (task != null)
+                               {
+                                       task.cancel();
+                               }
+                       }
+               });
+       }
+
+       private void startPushes()
+       {
+               String key = taskKey();
+               PushTask task = new PushTask(key, getApplication().getName(), 
getSession().getId(),
+                       getPageId(), veil.getVeilMessage());
+               if (TASKS.putIfAbsent(key, task) == null)
+               {
+                       
JSR356Application.get().getScheduledExecutorService().execute(task);
+               }
+       }
+
+       private String taskKey()
+       {
+               return getSession().getId() + "#" + getPageId();
+       }
+
+       @Override
+       public void onEvent(IEvent<?> event)
+       {
+               super.onEvent(event);
+
+               if (event.getPayload() instanceof WebSocketPushPayload payload 
&&
+                       payload.getMessage() instanceof CounterUpdate update)
+               {
+                       counter.setDefaultModelObject(update.value);
+                       lastWork.setDefaultModelObject(String.format(
+                               "Update %d of %d took %d ms on the server.", 
update.value, ROUNDS,
+                               update.work.toMillis()));
+                       payload.getHandler().add(counterPanel);
+                       veil.unveil(payload.getHandler());
+               }
+       }
+
+       @Override
+       public void renderHead(IHeaderResponse response)
+       {
+               super.renderHead(response);
+
+               response.render(CssHeaderItem.forReference(
+                       new CssResourceReference(WebSocketVeilDemoPage.class, 
"WebSocketVeilDemoPage.css")));
+       }
+
+       /**
+        * Pushes the new counter value, and how long it took to compute.
+        */
+       private static class CounterUpdate implements IWebSocketPushMessage
+       {
+               private final int value;
+
+               private final Duration work;
+
+               CounterUpdate(int value, Duration work)
+               {
+                       this.value = value;
+                       this.work = work;
+               }
+       }
+
+       /**
+        * Recomputes the counter a number of times, alternating long and short 
work. Both outlast the
+        * spinner delay; the short work ends within the spinner's minimum 
time, so the spinner stays
+        * on the redrawn panel for the rest of it.
+        */
+       private static class PushTask implements Runnable
+       {
+               private final String key;
+
+               private final String applicationName;
+
+               private final String sessionId;
+
+               private final int pageId;
+
+               private final String veilMessage;
+
+               private volatile boolean canceled;
+
+               PushTask(String key, String applicationName, String sessionId, 
int pageId,
+                       String veilMessage)
+               {
+                       this.key = key;
+                       this.applicationName = applicationName;
+                       this.sessionId = sessionId;
+                       this.pageId = pageId;
+                       this.veilMessage = veilMessage;
+               }
+
+               void cancel()
+               {
+                       canceled = true;
+               }
+
+               @Override
+               public void run()
+               {
+                       try
+                       {
+                               for (int round = 1; round <= ROUNDS && 
!canceled; round++)
+                               {
+                                       IWebSocketConnection connection = 
connection();
+                                       if (connection == null || 
!connection.isOpen())
+                                       {
+                                               return;
+                                       }
+
+                                       connection.sendMessage(veilMessage);
+                                       Duration work = Duration.ofMillis(round 
% 2 == 1 ? 1500 : 600);
+                                       
TimeUnit.MILLISECONDS.sleep(work.toMillis());
+                                       connection.sendMessage(new 
CounterUpdate(round, work));
+
+                                       TimeUnit.SECONDS.sleep(1);
+                               }
+                       }
+                       catch (InterruptedException e)
+                       {
+                               Thread.currentThread().interrupt();
+                       }
+                       catch (IOException | RuntimeException e)
+                       {
+                               LOGGER.error("Pushing to the counter panel 
failed", e);
+                       }
+                       finally
+                       {
+                               TASKS.remove(key);
+                       }
+               }
+
+               private IWebSocketConnection connection()
+               {
+                       Application application = 
Application.get(applicationName);
+                       return WebSocketSettings.Holder.get(application)
+                               .getConnectionRegistry()
+                               .getConnection(application, sessionId, new 
PageIdKey(pageId));
+               }
+       }
+}
diff --git 
a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties
 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties
new file mode 100644
index 0000000000..9bf32492af
--- /dev/null
+++ 
b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties
@@ -0,0 +1,17 @@
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You 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.
+title=Veil a component while the server recomputes it
+description=raises a local veil from the server and lifts it with the pushed 
update.
+explanation=<p> "Start pushes" starts a background task bound to this page. 
Ten times, it tells the browser through the WebSocket connection that the 
counter panel is being recomputed, which raises the panel's LocalVeilBehavior, 
works for a while, and pushes the new counter. The update lifts the veil. Odd 
rounds take 1.5 s: the spinner shows after 300 ms and goes away with the 
update. Even rounds take 600 ms: the spinner shows after 300 ms, and since the 
update arrives within its minimum [...]
diff --git 
a/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java
 
b/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java
new file mode 100644
index 0000000000..424f5d624d
--- /dev/null
+++ 
b/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java
@@ -0,0 +1,398 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.examples.ajax.builtin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.wicket.examples.AjaxEngineSelector.Engine;
+import org.apache.wicket.examples.JettyTestCaseDecorator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.openqa.selenium.By;
+import org.openqa.selenium.ElementClickInterceptedException;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.chrome.ChromeDriver;
+import org.openqa.selenium.chrome.ChromeOptions;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+/**
+ * Drives {@link VeilPage} in a headless Chrome, with either Ajax engine.
+ * <p>
+ * Needs Chrome, so it only runs with {@code -Dwicket.selenium=true}. Selenium 
Manager resolves
+ * the driver, and the browser too when none is installed.
+ */
+@EnabledIfSystemProperty(named = "wicket.selenium", matches = "true")
+class VeilPageSeleniumTest extends JettyTestCaseDecorator
+{
+       private static final String PAGE_VEIL = "body > .wicket-veil";
+
+       private static final String OUTER_VEIL = ".veil-outer > .wicket-veil";
+
+       private static final String INNER_VEIL = ".veil-inner > .wicket-veil";
+
+       /**
+        * Logs when a veil is added, gets its spinner and is removed, with the 
time of each, into
+        * {@code window.veilLog}.
+        */
+       private static final String RECORD_VEILS = """
+               window.veilLog = [];
+               function isVeil(node) {
+                       return node.classList && 
node.classList.contains('wicket-veil');
+               }
+               function log(event, veil, host) {
+                       window.veilLog.push({
+                               event: event,
+                               target: host === document.body ? 'page' :
+                                       host.classList.contains('veil-inner') ? 
'inner' : 'outer',
+                               time: performance.now()
+                       });
+               }
+               new MutationObserver(function (records) {
+                       records.forEach(function (record) {
+                               if (record.type === 'childList') {
+                                       record.addedNodes.forEach(function 
(node) {
+                                               if (isVeil(node)) { 
log('added', node, record.target); }
+                                       });
+                                       record.removedNodes.forEach(function 
(node) {
+                                               if (isVeil(node)) { 
log('removed', node, record.target); }
+                                       });
+                               } else if (isVeil(record.target) &&
+                                       
record.target.classList.contains('wicket-veil-busy') &&
+                                       (record.oldValue || 
'').indexOf('wicket-veil-busy') < 0) {
+                                       log('busy', record.target, 
record.target.parentNode);
+                               }
+                       });
+               }).observe(document.body, { childList: true, subtree: true, 
attributes: true,
+                       attributeOldValue: true, attributeFilter: ['class'] });
+               """;
+
+       /**
+        * Calls back once no veil has been on the page for 300 ms, so the 
requests the page fires by
+        * itself on load are over.
+        */
+       private static final String AWAIT_QUIET = """
+               var callback = arguments[arguments.length - 1];
+               var quietSince = null;
+               (function poll() {
+                       var now = Date.now();
+                       if (document.querySelector('.wicket-veil')) {
+                               quietSince = null;
+                       } else if (quietSince === null) {
+                               quietSince = now;
+                       } else if (now - quietSince >= 300) {
+                               callback(true);
+                               return;
+                       }
+                       setTimeout(poll, 20);
+               })();
+               """;
+
+       /**
+        * Tells whether the page veil is the topmost element at the centre of 
the given element, and
+        * describes the veil's geometry when it is not.
+        */
+       private static final String HIT_TEST = """
+               var target = arguments[0];
+               target.scrollIntoView({ block: 'center' });
+               var rect = target.getBoundingClientRect();
+               var x = rect.left + rect.width / 2;
+               var y = rect.top + rect.height / 2;
+               var hit = document.elementFromPoint(x, y);
+               var veil = document.querySelector('body > .wicket-veil');
+               var style = getComputedStyle(veil);
+               var veilRect = veil.getBoundingClientRect();
+               return {
+                       veiled: hit === veil,
+                       hit: hit ? hit.tagName + '.' + hit.className : null,
+                       point: x + ',' + y,
+                       position: style.position,
+                       zIndex: style.zIndex,
+                       veilRect: [veilRect.left, veilRect.top, veilRect.width, 
veilRect.height].join(','),
+                       stylesheet: 
Array.prototype.some.call(document.styleSheets, function (sheet) {
+                               return (sheet.href || 
'').indexOf('wicket-veil') >= 0;
+                       })
+               };
+               """;
+
+       private WebDriver driver;
+
+       private WebDriverWait wait;
+
+       @Override
+       @BeforeEach
+       public void before() throws Exception
+       {
+               super.before();
+
+               ChromeOptions options = new ChromeOptions();
+               options.addArguments("--headless=new", 
"--window-size=1280,1024");
+               driver = new ChromeDriver(options);
+               
driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(10));
+               wait = new WebDriverWait(driver, Duration.ofSeconds(10), 
Duration.ofMillis(20));
+       }
+
+       @Override
+       @AfterEach
+       public void after() throws Exception
+       {
+               if (driver != null)
+               {
+                       driver.quit();
+               }
+               super.after();
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void slowRequestVeilsThePageAndShowsTheSpinnerAfterTheDelay(Engine 
engine)
+       {
+               open(engine);
+
+               click("Slow request");
+               
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(PAGE_VEIL)));
+               wait.until(ExpectedConditions.presenceOfElementLocated(
+                       By.cssSelector(PAGE_VEIL + ".wicket-veil-busy")));
+               awaitPageCounter("1");
+               awaitNoVeil();
+
+               List<Map<String, Object>> log = veilLog();
+               assertEquals(List.of("added", "busy", "removed"), events(log), 
log.toString());
+               assertEquals("page", log.get(0).get("target"));
+               double spinnerDelay = time(log, "busy") - time(log, "added");
+               assertTrue(spinnerDelay >= 290, "the spinner showed after " + 
spinnerDelay + " ms");
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void theVeilSwallowsClicks(Engine engine)
+       {
+               open(engine);
+
+               click("Slow request");
+               
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(PAGE_VEIL)));
+               @SuppressWarnings("unchecked")
+               Map<String, Object> hit = (Map<String, 
Object>)js().executeScript(HIT_TEST,
+                       driver.findElement(By.linkText("Fast request")));
+               assertEquals(Boolean.TRUE, hit.get("veiled"), "the veil does 
not cover the link: " + hit);
+               assertThrows(ElementClickInterceptedException.class, () -> 
click("Fast request"));
+
+               awaitPageCounter("1");
+               awaitNoVeil();
+               awaitQuiet();
+               assertEquals("1", pageCounter(), "the click on the veiled page 
went through");
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void fastRequestShowsNoSpinner(Engine engine)
+       {
+               open(engine);
+
+               click("Fast request");
+               awaitPageCounter("1");
+               awaitNoVeil();
+
+               List<Map<String, Object>> log = veilLog();
+               assertEquals(List.of("added", "removed"), events(log), 
log.toString());
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void theSpinnerStaysForItsMinimumTime(Engine engine)
+       {
+               open(engine);
+
+               click("Medium request");
+               awaitPageCounter("1");
+               awaitNoVeil();
+
+               List<Map<String, Object>> log = veilLog();
+               assertEquals(List.of("added", "busy", "removed"), events(log), 
log.toString());
+               double shown = time(log, "removed") - time(log, "busy");
+               assertTrue(shown >= 480, "the spinner was shown for " + shown + 
" ms only");
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void anOptedOutRequestLeavesThePageUsable(Engine engine)
+       {
+               open(engine);
+
+               click("Unveiled request");
+               sleep(Duration.ofMillis(400));
+               assertTrue(veilLog().isEmpty(), "the opted-out request was 
veiled: " + veilLog());
+
+               click("Fast request");
+               awaitPageCounter("2");
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void aLocalVeilCoversOnlyItsComponentAndShowsTheSpinner(Engine engine)
+       {
+               open(engine);
+
+               click("Slow request from the outer panel");
+               
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(OUTER_VEIL)));
+               
assertTrue(outerPanel().getDomAttribute("class").contains("wicket-veil-host"));
+               
assertTrue(driver.findElements(By.cssSelector(PAGE_VEIL)).isEmpty(),
+                       "the page was veiled too");
+               
assertTrue(driver.findElements(By.cssSelector(INNER_VEIL)).isEmpty(),
+                       "the nested panel got a veil of its own");
+               wait.until(ExpectedConditions.presenceOfElementLocated(
+                       By.cssSelector(OUTER_VEIL + ".wicket-veil-busy")));
+
+               click("Fast request");
+
+               
wait.until(ExpectedConditions.textToBe(By.cssSelector(".outer-counter"), "1"));
+               awaitPageCounter("1");
+               awaitNoVeil();
+               
assertFalse(outerPanel().getDomAttribute("class").contains("wicket-veil-host"),
+                       "the host class stayed");
+
+               List<Map<String, Object>> outerLog = veilLog().stream()
+                       .filter(entry -> "outer".equals(entry.get("target")))
+                       .collect(Collectors.toList());
+               assertEquals(List.of("added", "busy", "removed"), 
events(outerLog), outerLog.toString());
+               double spinnerDelay = time(outerLog, "busy") - time(outerLog, 
"added");
+               assertTrue(spinnerDelay >= 290, "the spinner showed after " + 
spinnerDelay + " ms");
+       }
+
+       @ParameterizedTest
+       @EnumSource(Engine.class)
+       void aNestedLocalVeilTakesTheRequestWithItsOwnTimings(Engine engine)
+       {
+               open(engine);
+
+               click("Short request from the inner panel");
+               
wait.until(ExpectedConditions.textToBe(By.cssSelector(".inner-counter"), "1"));
+               awaitNoVeil();
+
+               List<Map<String, Object>> log = veilLog();
+               assertEquals(List.of("added", "busy", "removed"), events(log), 
log.toString());
+               assertTrue(log.stream().allMatch(entry -> 
"inner".equals(entry.get("target"))),
+                       "a veil other than the inner one was involved: " + log);
+               double spinnerDelay = time(log, "busy") - time(log, "added");
+               assertTrue(spinnerDelay >= 90 && spinnerDelay < 290,
+                       "the spinner showed after " + spinnerDelay + " ms 
instead of the configured 100 ms");
+               double shown = time(log, "removed") - time(log, "busy");
+               assertTrue(shown >= 980,
+                       "the spinner was shown for " + shown + " ms instead of 
the configured 1 s");
+       }
+
+       private WebElement outerPanel()
+       {
+               return driver.findElement(By.cssSelector(".veil-outer"));
+       }
+
+       private void open(Engine engine)
+       {
+               
driver.get(String.format("http://localhost:%d/wicket-examples/ajax/veil";, 
localPort));
+               if (isJQueryLoaded() != (engine == Engine.JQUERY))
+               {
+                       WebElement toggle = 
driver.findElement(By.partialLinkText("switch to"));
+                       toggle.click();
+                       wait.until(ExpectedConditions.stalenessOf(toggle));
+                       
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".veil-outer")));
+               }
+               assertEquals(engine == Engine.JQUERY, isJQueryLoaded(), "the 
Ajax engine");
+
+               awaitQuiet();
+               js().executeScript(RECORD_VEILS);
+       }
+
+       private boolean isJQueryLoaded()
+       {
+               return (Boolean)js().executeScript("return typeof window.jQuery 
=== 'function';");
+       }
+
+       private void click(String linkText)
+       {
+               driver.findElement(By.linkText(linkText)).click();
+       }
+
+       private String pageCounter()
+       {
+               return 
driver.findElement(By.cssSelector(".page-counter")).getText();
+       }
+
+       private void awaitPageCounter(String value)
+       {
+               
wait.until(ExpectedConditions.textToBe(By.cssSelector(".page-counter"), value));
+       }
+
+       private void awaitNoVeil()
+       {
+               
wait.until(ExpectedConditions.numberOfElementsToBe(By.cssSelector(".wicket-veil"),
 0));
+       }
+
+       private void awaitQuiet()
+       {
+               js().executeAsyncScript(AWAIT_QUIET);
+       }
+
+       @SuppressWarnings("unchecked")
+       private List<Map<String, Object>> veilLog()
+       {
+               return (List<Map<String, Object>>)js().executeScript("return 
window.veilLog;");
+       }
+
+       private static List<Object> events(List<Map<String, Object>> log)
+       {
+               return log.stream().map(entry -> 
entry.get("event")).collect(Collectors.toList());
+       }
+
+       private static double time(List<Map<String, Object>> log, String event)
+       {
+               return log.stream()
+                       .filter(entry -> event.equals(entry.get("event")))
+                       .map(entry -> ((Number)entry.get("time")).doubleValue())
+                       .findFirst()
+                       .orElseThrow(() -> new AssertionError("no '" + event + 
"' in " + log));
+       }
+
+       private JavascriptExecutor js()
+       {
+               return (JavascriptExecutor)driver;
+       }
+
+       private static void sleep(Duration duration)
+       {
+               try
+               {
+                       Thread.sleep(duration.toMillis());
+               }
+               catch (InterruptedException e)
+               {
+                       Thread.currentThread().interrupt();
+               }
+       }
+}
diff --git a/wicket-extensions/pom.xml b/wicket-extensions/pom.xml
index 8e858528a9..ac63c5cf8b 100644
--- a/wicket-extensions/pom.xml
+++ b/wicket-extensions/pom.xml
@@ -40,6 +40,7 @@ 
org.apache.wicket.extensions.ajax.markup.html.repeater;-noimport:=true,
 
org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort;-noimport:=true,
 
org.apache.wicket.extensions.ajax.markup.html.repeater.data.table;-noimport:=true,
 org.apache.wicket.extensions.ajax.markup.html.tabs;-noimport:=true,
+org.apache.wicket.extensions.ajax.veil;-noimport:=true,
 org.apache.wicket.extensions.breadcrumb;-noimport:=true,
 org.apache.wicket.extensions.breadcrumb.panel;-noimport:=true,
 org.apache.wicket.extensions.captcha.kittens;-noimport:=true,
diff --git a/wicket-extensions/src/main/java/module-info.java 
b/wicket-extensions/src/main/java/module-info.java
index 33e4303fc0..cc2554a6fe 100644
--- a/wicket-extensions/src/main/java/module-info.java
+++ b/wicket-extensions/src/main/java/module-info.java
@@ -40,6 +40,7 @@ module org.apache.wicket.extensions {
     exports org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort;
     exports org.apache.wicket.extensions.ajax.markup.html.repeater.data.table;
     exports org.apache.wicket.extensions.ajax.markup.html.tabs;
+    exports org.apache.wicket.extensions.ajax.veil;
     exports org.apache.wicket.extensions.breadcrumb;
     exports org.apache.wicket.extensions.breadcrumb.panel;
     exports org.apache.wicket.extensions.captcha.kittens;
@@ -82,6 +83,7 @@ module org.apache.wicket.extensions {
     opens org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort;
     opens org.apache.wicket.extensions.ajax.markup.html.modal;
     opens org.apache.wicket.extensions.ajax.markup.html.modal.theme;
+    opens org.apache.wicket.extensions.ajax.veil;
     opens org.apache.wicket.extensions.breadcrumb;
     opens org.apache.wicket.extensions.captcha.kittens;
     opens org.apache.wicket.extensions.captcha.kittens.images;
diff --git 
a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java
 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java
new file mode 100644
index 0000000000..1acf840c5a
--- /dev/null
+++ 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.extensions.ajax.veil;
+
+import java.time.Duration;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.behavior.Behavior;
+import org.apache.wicket.markup.head.CssHeaderItem;
+import org.apache.wicket.markup.head.IHeaderResponse;
+import org.apache.wicket.markup.head.JavaScriptHeaderItem;
+import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
+import org.apache.wicket.request.resource.CssResourceReference;
+import org.apache.wicket.request.resource.JavaScriptResourceReference;
+import org.apache.wicket.request.resource.ResourceReference;
+import org.apache.wicket.resource.CoreLibrariesContributor;
+import org.apache.wicket.util.lang.Args;
+
+/**
+ * Base class of the behaviors that put a veil over a region of the page while 
Ajax requests are
+ * in flight.
+ * <p>
+ * The veil appears as soon as a request is sent. It is transparent and 
swallows mouse clicks, so
+ * the user cannot fire further requests or change what the pending one is 
about. Only if the
+ * request is still running after {@link #getSpinnerDelay()} does the veil get 
the CSS class
+ * {@code wicket-veil-busy}, which dims the region and shows a spinner; once 
shown, the spinner
+ * stays for at least {@link #getMinimumSpinnerTime()}, so a response arriving 
just after it
+ * appeared does not make it flicker. Both timings apply to the page veil and 
to local veils
+ * alike, and can be changed per behavior with {@link 
#setSpinnerDelay(Duration)} and
+ * {@link #setMinimumSpinnerTime(Duration)}. The veil does not intercept the 
keyboard.
+ * <p>
+ * The look comes from {@code wicket-veil.css} and can be overridden with the 
classes
+ * {@code wicket-veil}, {@code wicket-veil-busy} and {@code wicket-veil-host}.
+ * <p>
+ * A request is left unveiled when it carries the extra parameter
+ * {@value PageVeilBehavior#NO_VEIL_PARAMETER}, see {@link 
PageVeilBehavior#noVeil}.
+ *
+ * @see PageVeilBehavior
+ * @see LocalVeilBehavior
+ * @since 11.0.0
+ */
+public abstract class AbstractVeilBehavior extends Behavior
+{
+       private static final long serialVersionUID = 1L;
+
+       private static final ResourceReference JS = new 
JavaScriptResourceReference(
+               AbstractVeilBehavior.class, "wicket-veil.js");
+
+       private static final ResourceReference CSS = new CssResourceReference(
+               AbstractVeilBehavior.class, "wicket-veil.css");
+
+       private Duration spinnerDelay = Duration.ofMillis(300);
+
+       private Duration minimumSpinnerTime = Duration.ofMillis(500);
+
+       /**
+        * @return how long a request has to run before the spinner is shown; 
300 ms by default
+        */
+       protected Duration getSpinnerDelay()
+       {
+               return spinnerDelay;
+       }
+
+       /**
+        * Sets how long a request has to run before the spinner is shown. 
{@link Duration#ZERO}
+        * shows it as soon as the request is sent.
+        *
+        * @param spinnerDelay
+        *            the delay, not negative
+        * @return this, for chaining
+        */
+       public AbstractVeilBehavior setSpinnerDelay(Duration spinnerDelay)
+       {
+               this.spinnerDelay = checkNotNegative(spinnerDelay, 
"spinnerDelay");
+               return this;
+       }
+
+       /**
+        * @return how long the spinner stays at least, once it is shown; 500 
ms by default
+        */
+       protected Duration getMinimumSpinnerTime()
+       {
+               return minimumSpinnerTime;
+       }
+
+       /**
+        * Sets how long the spinner stays at least, once it is shown, even 
when the request is over
+        * sooner. {@link Duration#ZERO} removes it together with the request.
+        *
+        * @param minimumSpinnerTime
+        *            the minimum time, not negative
+        * @return this, for chaining
+        */
+       public AbstractVeilBehavior setMinimumSpinnerTime(Duration 
minimumSpinnerTime)
+       {
+               this.minimumSpinnerTime = checkNotNegative(minimumSpinnerTime, 
"minimumSpinnerTime");
+               return this;
+       }
+
+       private static Duration checkNotNegative(Duration duration, String name)
+       {
+               Args.notNull(duration, name);
+               if (duration.isNegative())
+               {
+                       throw new IllegalArgumentException(name + " must not be 
negative: " + duration);
+               }
+               return duration;
+       }
+
+       @Override
+       public void renderHead(Component component, IHeaderResponse response)
+       {
+               super.renderHead(component, response);
+
+               
CoreLibrariesContributor.contributeAjax(component.getApplication(), response);
+               response.render(JavaScriptHeaderItem.forReference(JS));
+               response.render(CssHeaderItem.forReference(CSS));
+               
response.render(OnDomReadyHeaderItem.forScript(getInitScript(component)));
+       }
+
+       /**
+        * @param component
+        *            the component this behavior is bound to
+        * @return the script registering the veil with {@code Wicket.Veil}
+        */
+       protected abstract CharSequence getInitScript(Component component);
+
+       /**
+        * @return the timings as the options object {@code Wicket.Veil} expects
+        */
+       protected final String getOptions()
+       {
+               return String.format("{\"delay\":%d,\"minimum\":%d}", 
getSpinnerDelay().toMillis(),
+                       getMinimumSpinnerTime().toMillis());
+       }
+}
diff --git 
a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java
 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java
new file mode 100644
index 0000000000..cbd0854aa8
--- /dev/null
+++ 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.extensions.ajax.veil;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.core.request.handler.IPartialPageRequestHandler;
+
+import com.github.openjson.JSONObject;
+
+/**
+ * Veils a single component while an Ajax request fired from inside it is in 
flight, see
+ * {@link AbstractVeilBehavior}.
+ * <p>
+ * Requests fired by the component itself or by any component nested in it are 
covered; when
+ * local veils are nested, the innermost one takes the request. Such a request 
does not veil the
+ * page, even when it has a {@link PageVeilBehavior}. Requests fired from 
elsewhere leave the
+ * component alone.
+ * <p>
+ * The veil is appended to the component's element, which gets the class
+ * {@code wicket-veil-host} with {@code position: relative} for the duration, 
so the component
+ * has to render an element that can hold a {@code div}. The behavior makes 
the component output
+ * its markup id.
+ * <p>
+ * The server can raise the veil too, for an update it is about to push to the 
component, for
+ * example through a WebSocket connection: send {@link #getVeilMessage()} as a 
text message when
+ * the work for the component starts, and call {@link 
#unveil(IPartialPageRequestHandler)} with
+ * the handler that pushes the updated component. The spinner follows the same 
timings as for an
+ * Ajax request. When the work fails and no update follows, send {@link 
#getUnveilMessage()}
+ * instead. The text messages are understood by the client of Wicket's native 
WebSocket support,
+ * which hands them to {@code Wicket.Veil}:
+ *
+ * <pre>
+ * // while handling a request, e.g. the click that schedules the work
+ * String veilMessage = veil.getVeilMessage();
+ *
+ * // when the work starts, from any thread
+ * connection.sendMessage(veilMessage);
+ *
+ * // when the result is pushed, e.g. in onEvent() for a WebSocketPushPayload
+ * handler.add(component);
+ * veil.unveil(handler);
+ * </pre>
+ *
+ * A behavior instance belongs to a single component.
+ *
+ * @since 11.0.0
+ */
+public class LocalVeilBehavior extends AbstractVeilBehavior
+{
+       private static final long serialVersionUID = 1L;
+
+       private Component component;
+
+       @Override
+       public void bind(Component component)
+       {
+               super.bind(component);
+
+               if (this.component != null && this.component != component)
+               {
+                       throw new 
IllegalStateException(LocalVeilBehavior.class.getSimpleName() +
+                               " is already bound to " + 
this.component.getPageRelativePath() +
+                               " and cannot be bound to another component");
+               }
+               this.component = component;
+               component.setOutputMarkupId(true);
+       }
+
+       @Override
+       public void unbind(Component component)
+       {
+               this.component = null;
+
+               super.unbind(component);
+       }
+
+       /**
+        * A text message that raises this veil on the client when it is sent 
through a WebSocket
+        * connection of the component's page. Each one has to be matched by
+        * {@link #unveil(IPartialPageRequestHandler)} or {@link 
#getUnveilMessage()}.
+        * <p>
+        * Like any component access, call it while handling a request; the 
message itself is a plain
+        * string that can then be sent from any thread.
+        *
+        * @return the message
+        * @throws IllegalStateException
+        *             if the behavior is not bound to a component
+        */
+       public String getVeilMessage()
+       {
+               return message("show");
+       }
+
+       /**
+        * A text message that lowers this veil on the client when it is sent 
through a WebSocket
+        * connection of the component's page, for when no update follows a
+        * {@link #getVeilMessage()}.
+        *
+        * @return the message
+        * @throws IllegalStateException
+        *             if the behavior is not bound to a component
+        */
+       public String getUnveilMessage()
+       {
+               return message("hide");
+       }
+
+       /**
+        * Lowers this veil on the client once the given handler's update has 
been applied, matching
+        * an earlier {@link #getVeilMessage()}.
+        *
+        * @param handler
+        *            the handler pushing the update of the component
+        * @throws IllegalStateException
+        *             if the behavior is not bound to a component
+        */
+       public void unveil(IPartialPageRequestHandler handler)
+       {
+               handler.appendJavaScript("Wicket.Veil.hide(" + quotedMarkupId() 
+ ");");
+       }
+
+       private String message(String command)
+       {
+               return "{\"wicketVeil\":\"" + command + "\",\"id\":" + 
quotedMarkupId() + "}";
+       }
+
+       private String quotedMarkupId()
+       {
+               if (component == null)
+               {
+                       throw new IllegalStateException(
+                               LocalVeilBehavior.class.getSimpleName() + " is 
not bound to a component");
+               }
+               return JSONObject.quote(component.getMarkupId());
+       }
+
+       @Override
+       protected CharSequence getInitScript(Component component)
+       {
+               return "Wicket.Veil.local(" + 
JSONObject.quote(component.getMarkupId()) + ", " +
+                       getOptions() + ");";
+       }
+}
diff --git 
a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java
 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java
new file mode 100644
index 0000000000..fa1047d541
--- /dev/null
+++ 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.extensions.ajax.veil;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.Page;
+import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
+
+/**
+ * Veils the whole page while an Ajax request is in flight, see {@link 
AbstractVeilBehavior}.
+ * <p>
+ * Add it to a page, typically a base page, to cover every Ajax request fired 
from it. A request
+ * fired from inside a component with a {@link LocalVeilBehavior} veils only 
that component
+ * instead. Requests the user did not start, such as those of an Ajax timer, 
are veiled as well.
+ * To leave a request unveiled, call {@link #noVeil(AjaxRequestAttributes)} 
from the
+ * {@code updateAjaxAttributes} of its behavior:
+ *
+ * <pre>
+ * &#064;Override
+ * protected void updateAjaxAttributes(AjaxRequestAttributes attributes)
+ * {
+ *     super.updateAjaxAttributes(attributes);
+ *     PageVeilBehavior.noVeil(attributes);
+ * }
+ * </pre>
+ *
+ * @since 11.0.0
+ */
+public class PageVeilBehavior extends AbstractVeilBehavior
+{
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * The extra request parameter exempting an Ajax request from any veil, 
page or local.
+        */
+       public static final String NO_VEIL_PARAMETER = "wicket_nb";
+
+       /**
+        * Exempts an Ajax request from being veiled, by the page veil as well 
as by any
+        * {@link LocalVeilBehavior}. It adds the extra parameter {@value 
#NO_VEIL_PARAMETER}, so the
+        * request carries it to the server too.
+        *
+        * @param attributes
+        *            the attributes of the request to exempt
+        */
+       public static void noVeil(AjaxRequestAttributes attributes)
+       {
+               attributes.getExtraParameters().put(NO_VEIL_PARAMETER, "true");
+       }
+
+       /**
+        * @throws IllegalArgumentException
+        *             if the component is not a {@link Page}
+        */
+       @Override
+       public void bind(Component component)
+       {
+               super.bind(component);
+
+               if (component instanceof Page == false)
+               {
+                       throw new 
IllegalArgumentException(PageVeilBehavior.class.getSimpleName() +
+                               " can only be added to a page, not to " + 
component.getClass().getName());
+               }
+       }
+
+       @Override
+       protected CharSequence getInitScript(Component component)
+       {
+               return "Wicket.Veil.page(" + getOptions() + ");";
+       }
+}
diff --git 
a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css
 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css
new file mode 100644
index 0000000000..6736ec668e
--- /dev/null
+++ 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+.wicket-veil {
+       position: fixed;
+       top: 0;
+       right: 0;
+       bottom: 0;
+       left: 0;
+       z-index: 10000;
+       background: transparent;
+       cursor: wait;
+}
+
+.wicket-veil-host {
+       position: relative;
+}
+
+.wicket-veil-host > .wicket-veil {
+       position: absolute;
+       z-index: 1000;
+}
+
+.wicket-veil-busy {
+       background: rgba(255, 255, 255, 0.5);
+}
+
+.wicket-veil-busy::after {
+       content: "";
+       position: absolute;
+       top: 50%;
+       left: 50%;
+       box-sizing: border-box;
+       width: 2.5em;
+       height: 2.5em;
+       margin: -1.25em 0 0 -1.25em;
+       border: 0.3em solid rgba(0, 0, 0, 0.15);
+       border-top-color: rgba(0, 0, 0, 0.6);
+       border-radius: 50%;
+       animation: wicket-veil-spin 0.8s linear infinite;
+}
+
+@keyframes wicket-veil-spin {
+       to {
+               transform: rotate(360deg);
+       }
+}
diff --git 
a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js
 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js
new file mode 100644
index 0000000000..c207dfe3f9
--- /dev/null
+++ 
b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js
@@ -0,0 +1,324 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/*
+ * Veils the page, or a single component, while Ajax requests are in flight.
+ *
+ * The veil is transparent and only blocks the mouse. If a request is still 
running after the
+ * target's spinner delay, the veil gets the 'wicket-veil-busy' class, which 
shows a spinner;
+ * once shown, the spinner stays for at least the target's minimum time, so it 
does not flicker.
+ * A request carrying the extra parameter 'wicket_nb' is never veiled.
+ *
+ * A local veil can also be raised by the server, for a component it is about 
to update through a
+ * WebSocket push: a WebSocket text message {"wicketVeil":"show","id":"<markup 
id>"} raises it,
+ * Wicket.Veil.hide(id) - evaluated after the pushed update - or 
{"wicketVeil":"hide",...} lowers it.
+ */
+;(function (undefined) {
+       'use strict';
+
+       if (typeof(Wicket.Veil) === "object") {
+               return;
+       }
+
+       const NO_VEIL_PARAMETER = 'wicket_nb';
+       const VEIL_CLASS = 'wicket-veil';
+       const BUSY_CLASS = 'wicket-veil-busy';
+       const HOST_CLASS = 'wicket-veil-host';
+       const WEBSOCKET_MESSAGE_TOPIC = '/websocket/message';
+       const MESSAGE_PREFIX = '{"wicketVeil"';
+
+       let pageTarget = null;
+       let localTargets = {};
+       let subscribed = false;
+
+       function createTarget(id, options) {
+               return {
+                       id: id,
+                       delay: options.delay,
+                       minimum: options.minimum,
+                       count: 0,
+                       host: null,
+                       veil: null,
+                       shownAt: -1,
+                       spinnerTimer: null,
+                       hideTimer: null
+               };
+       }
+
+       function configure(target, options) {
+               target.delay = options.delay;
+               target.minimum = options.minimum;
+       }
+
+       function isOptedOut(attrs) {
+               const ep = attrs.ep;
+               if (Array.isArray(ep)) {
+                       return ep.some(function (parameter) {
+                               return parameter && parameter.name === 
NO_VEIL_PARAMETER;
+                       });
+               }
+               return !!ep && typeof(ep) === "object" &&
+                       Object.prototype.hasOwnProperty.call(ep, 
NO_VEIL_PARAMETER);
+       }
+
+       function findTarget(attrs) {
+               let node = attrs.c ? document.getElementById(attrs.c) : null;
+               for (; node && node !== document; node = node.parentNode) {
+                       if (node.id && localTargets[node.id]) {
+                               return localTargets[node.id];
+                       }
+               }
+               return pageTarget;
+       }
+
+       function dropStaleTargets() {
+               for (const id in localTargets) {
+                       if (Object.prototype.hasOwnProperty.call(localTargets, 
id) &&
+                               localTargets[id].count === 0 && 
!document.getElementById(id)) {
+                               delete localTargets[id];
+                       }
+               }
+       }
+
+       function hide(target) {
+               const clock = Wicket.Veil._clock;
+               clock.clearTimeout(target.spinnerTimer);
+               clock.clearTimeout(target.hideTimer);
+               target.spinnerTimer = null;
+               target.hideTimer = null;
+               target.shownAt = -1;
+               if (target.veil && target.veil.parentNode) {
+                       target.veil.parentNode.removeChild(target.veil);
+               }
+               if (target.host && target !== pageTarget) {
+                       target.host.classList.remove(HOST_CLASS);
+               }
+               target.veil = null;
+               target.host = null;
+       }
+
+       function show(target) {
+               const clock = Wicket.Veil._clock;
+               if (target.hideTimer !== null) {
+                       if (document.body.contains(target.veil)) {
+                               // the previous request's spinner is still on 
its minimum time: carry on with it
+                               clock.clearTimeout(target.hideTimer);
+                               target.hideTimer = null;
+                               return;
+                       }
+                       hide(target);
+               }
+
+               const host = target === pageTarget ? document.body : 
document.getElementById(target.id);
+               const veil = document.createElement('div');
+               veil.className = VEIL_CLASS;
+               if (target !== pageTarget) {
+                       host.classList.add(HOST_CLASS);
+               }
+               host.appendChild(veil);
+               target.host = host;
+               target.veil = veil;
+
+               target.spinnerTimer = clock.setTimeout(function () {
+                       target.spinnerTimer = null;
+                       veil.classList.add(BUSY_CLASS);
+                       target.shownAt = clock.now();
+               }, target.delay);
+       }
+
+       function reattach(target) {
+               if (target === pageTarget || !target.veil || 
document.body.contains(target.veil)) {
+                       return;
+               }
+               const host = document.getElementById(target.id);
+               if (host) {
+                       host.classList.add(HOST_CLASS);
+                       host.appendChild(target.veil);
+                       target.host = host;
+               }
+       }
+
+       function release(target) {
+               const clock = Wicket.Veil._clock;
+               if (target.shownAt < 0) {
+                       hide(target);
+                       return;
+               }
+               const remaining = target.minimum - (clock.now() - 
target.shownAt);
+               if (remaining > 0) {
+                       // the update may have replaced the element, and the 
veil with it
+                       reattach(target);
+                       target.hideTimer = clock.setTimeout(function () {
+                               hide(target);
+                       }, remaining);
+               } else {
+                       hide(target);
+               }
+       }
+
+       function acquire(target) {
+               target.count++;
+               if (target.count === 1) {
+                       show(target);
+               }
+       }
+
+       function releaseOne(target) {
+               if (target.count > 0) {
+                       target.count--;
+                       if (target.count === 0) {
+                               release(target);
+                       }
+               }
+       }
+
+       function onBeforeSend(jqEvent, attrs) {
+               if (!attrs || isOptedOut(attrs)) {
+                       return;
+               }
+               dropStaleTargets();
+               const target = findTarget(attrs);
+               if (target === null) {
+                       return;
+               }
+               attrs.wicketVeil = target;
+               acquire(target);
+       }
+
+       function onDone(jqEvent, attrs) {
+               const target = attrs && attrs.wicketVeil;
+               if (!target) {
+                       return;
+               }
+               delete attrs.wicketVeil;
+               releaseOne(target);
+       }
+
+       function onWebSocketMessage(jqEvent, message) {
+               if (typeof(message) !== "string" || 
message.indexOf(MESSAGE_PREFIX) !== 0) {
+                       return;
+               }
+               let command;
+               try {
+                       command = JSON.parse(message);
+               } catch (e) {
+                       return;
+               }
+               if (command.wicketVeil === 'show') {
+                       Wicket.Veil.show(command.id);
+               } else if (command.wicketVeil === 'hide') {
+                       Wicket.Veil.hide(command.id);
+               }
+       }
+
+       function subscribe() {
+               if (subscribed === false) {
+                       subscribed = true;
+                       
Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_BEFORE_SEND, onBeforeSend);
+                       
Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_DONE, onDone);
+                       Wicket.Event.subscribe(WEBSOCKET_MESSAGE_TOPIC, 
onWebSocketMessage);
+               }
+       }
+
+       Wicket.Veil = {
+
+               /**
+                * Veils the whole page during every Ajax request that no local 
veil claims.
+                *
+                * @param options {Object} - 'delay': milliseconds before the 
spinner shows,
+                *      'minimum': milliseconds the spinner stays once shown
+                */
+               page: function (options) {
+                       subscribe();
+                       if (pageTarget === null) {
+                               pageTarget = createTarget(null, options);
+                       } else {
+                               configure(pageTarget, options);
+                       }
+               },
+
+               /**
+                * Veils only the element with the given id, during the Ajax 
requests fired by
+                * components inside it.
+                *
+                * @param id {String} - the markup id of the element to veil
+                * @param options {Object} - as for page()
+                */
+               local: function (id, options) {
+                       subscribe();
+                       const target = localTargets[id];
+                       if (target) {
+                               configure(target, options);
+                       } else {
+                               localTargets[id] = createTarget(id, options);
+                       }
+               },
+
+               /**
+                * Raises the local veil registered for the given id, as an 
Ajax request from inside it
+                * would, with the same timings. Each call has to be matched by 
a call to hide().
+                *
+                * @param id {String} - the markup id of a component with a 
local veil
+                */
+               show: function (id) {
+                       const target = localTargets[id];
+                       if (target && document.getElementById(id)) {
+                               acquire(target);
+                       }
+               },
+
+               /**
+                * Lowers the local veil raised by show(), respecting the 
spinner's minimum time. Calls
+                * without a matching show() are ignored.
+                *
+                * @param id {String} - the markup id of a component with a 
local veil
+                */
+               hide: function (id) {
+                       const target = localTargets[id];
+                       if (target) {
+                               releaseOne(target);
+                       }
+               },
+
+               _clock: {
+                       now: function () {
+                               return Date.now();
+                       },
+                       setTimeout: function (fn, delay) {
+                               return window.setTimeout(fn, delay);
+                       },
+                       clearTimeout: function (timer) {
+                               if (timer !== null) {
+                                       window.clearTimeout(timer);
+                               }
+                       }
+               },
+
+               _reset: function () {
+                       if (pageTarget !== null) {
+                               hide(pageTarget);
+                       }
+                       for (const id in localTargets) {
+                               if 
(Object.prototype.hasOwnProperty.call(localTargets, id)) {
+                                       hide(localTargets[id]);
+                               }
+                       }
+                       pageTarget = null;
+                       localTargets = {};
+               }
+       };
+})();
diff --git 
a/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java
 
b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java
new file mode 100644
index 0000000000..f714e49d2f
--- /dev/null
+++ 
b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.wicket.extensions.ajax.veil;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
+import org.apache.wicket.ajax.markup.html.AjaxLink;
+import org.apache.wicket.behavior.Behavior;
+import org.apache.wicket.markup.IMarkupResourceStreamProvider;
+import org.apache.wicket.markup.html.WebMarkupContainer;
+import org.apache.wicket.markup.html.WebPage;
+import org.apache.wicket.util.resource.IResourceStream;
+import org.apache.wicket.util.resource.StringResourceStream;
+import org.apache.wicket.util.tester.WicketTestCase;
+import org.danekja.java.util.function.serializable.SerializableConsumer;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link PageVeilBehavior} and {@link LocalVeilBehavior}.
+ */
+class VeilBehaviorTest extends WicketTestCase
+{
+       @Test
+       void pageVeilContributesResourcesAndInitScript()
+       {
+               tester.startPage(new TestPage(new PageVeilBehavior(), null, 
false));
+
+               String response = tester.getLastResponseAsString();
+               assertTrue(response.contains("wicket-veil.js"), response);
+               assertTrue(response.contains("wicket-veil.css"), response);
+               
assertTrue(response.contains("Wicket.Veil.page({\"delay\":300,\"minimum\":500});"),
+                       response);
+       }
+
+       @Test
+       void localVeilContributesInitScriptForItsComponent()
+       {
+               TestPage page = tester.startPage(new TestPage(null, new 
LocalVeilBehavior(), false));
+
+               String response = tester.getLastResponseAsString();
+               String markupId = page.container.getMarkupId();
+               assertTrue(response.contains("id=\"" + markupId + "\""), 
response);
+               assertTrue(response.contains("wicket-veil.js"), response);
+               assertTrue(response.contains(
+                       "Wicket.Veil.local(\"" + markupId + "\", 
{\"delay\":300,\"minimum\":500});"),
+                       response);
+       }
+
+       @Test
+       void timingsCanBeOverridden()
+       {
+               tester.startPage(new TestPage(new PageVeilBehavior()
+               {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected Duration getSpinnerDelay()
+                       {
+                               return Duration.ofMillis(100);
+                       }
+
+                       @Override
+                       protected Duration getMinimumSpinnerTime()
+                       {
+                               return Duration.ofSeconds(1);
+                       }
+               }, null, false));
+
+               String response = tester.getLastResponseAsString();
+               
assertTrue(response.contains("Wicket.Veil.page({\"delay\":100,\"minimum\":1000});"),
+                       response);
+       }
+
+       @Test
+       void localVeilTimingsCanBeSet()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+               
veil.setSpinnerDelay(Duration.ZERO).setMinimumSpinnerTime(Duration.ofSeconds(2));
+               TestPage page = tester.startPage(new TestPage(null, veil, 
false));
+
+               String response = tester.getLastResponseAsString();
+               assertTrue(response.contains("Wicket.Veil.local(\"" + 
page.container.getMarkupId() +
+                       "\", {\"delay\":0,\"minimum\":2000});"), response);
+       }
+
+       @Test
+       void negativeTimingsAreRejected()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+
+               assertThrows(IllegalArgumentException.class,
+                       () -> veil.setSpinnerDelay(Duration.ofMillis(-1)));
+               assertThrows(IllegalArgumentException.class,
+                       () -> 
veil.setMinimumSpinnerTime(Duration.ofMillis(-1)));
+       }
+
+       @Test
+       void veilMessagesNameTheComponent()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+               TestPage page = tester.startPage(new TestPage(null, veil, 
false));
+               String markupId = page.container.getMarkupId();
+
+               assertEquals("{\"wicketVeil\":\"show\",\"id\":\"" + markupId + 
"\"}",
+                       veil.getVeilMessage());
+               assertEquals("{\"wicketVeil\":\"hide\",\"id\":\"" + markupId + 
"\"}",
+                       veil.getUnveilMessage());
+       }
+
+       @Test
+       void unveilLowersTheVeilAfterTheUpdate()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+               TestPage page = tester.startPage(new TestPage(null, veil, 
false));
+               page.onClick = target -> {
+                       target.add(page.container);
+                       veil.unveil(target);
+               };
+
+               tester.clickLink("container:link");
+
+               String response = tester.getLastResponseAsString();
+               assertTrue(response.contains("Wicket.Veil.hide(\"" + 
page.container.getMarkupId() + "\");"),
+                       response);
+       }
+
+       @Test
+       void anUnboundLocalVeilHasNoMessages()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+
+               assertThrows(IllegalStateException.class, veil::getVeilMessage);
+       }
+
+       @Test
+       void aLocalVeilBelongsToOneComponent()
+       {
+               LocalVeilBehavior veil = new LocalVeilBehavior();
+               new WebMarkupContainer("first").add(veil);
+
+               assertThrows(IllegalStateException.class,
+                       () -> new WebMarkupContainer("second").add(veil));
+       }
+
+       @Test
+       void pageVeilRejectsNonPageComponents()
+       {
+               WebMarkupContainer container = new 
WebMarkupContainer("container");
+
+               assertThrows(IllegalArgumentException.class, () -> 
container.add(new PageVeilBehavior()));
+       }
+
+       @Test
+       void noVeilAddsTheExtraParameter()
+       {
+               tester.startPage(new TestPage(new PageVeilBehavior(), null, 
true));
+
+               String response = tester.getLastResponseAsString();
+               
assertTrue(response.contains("\"ep\":[{\"name\":\"wicket_nb\",\"value\":\"true\"}]"),
+                       response);
+       }
+
+       @Test
+       void requestsAreNotOptedOutByDefault()
+       {
+               tester.startPage(new TestPage(new PageVeilBehavior(), null, 
false));
+
+               
assertFalse(tester.getLastResponseAsString().contains("wicket_nb"));
+       }
+
+       /**
+        * A page with a container holding an Ajax link.
+        */
+       public static class TestPage extends WebPage implements 
IMarkupResourceStreamProvider
+       {
+               private static final long serialVersionUID = 1L;
+
+               final WebMarkupContainer container;
+
+               SerializableConsumer<AjaxRequestTarget> onClick = target -> {
+               };
+
+               TestPage(Behavior pageBehavior, Behavior containerBehavior, 
boolean noVeil)
+               {
+                       if (pageBehavior != null)
+                       {
+                               add(pageBehavior);
+                       }
+
+                       add(container = new WebMarkupContainer("container"));
+                       if (containerBehavior != null)
+                       {
+                               container.add(containerBehavior);
+                       }
+
+                       container.add(new AjaxLink<Void>("link")
+                       {
+                               private static final long serialVersionUID = 1L;
+
+                               @Override
+                               protected void 
updateAjaxAttributes(AjaxRequestAttributes attributes)
+                               {
+                                       super.updateAjaxAttributes(attributes);
+                                       if (noVeil)
+                                       {
+                                               
PageVeilBehavior.noVeil(attributes);
+                                       }
+                               }
+
+                               @Override
+                               public void onClick(AjaxRequestTarget target)
+                               {
+                                       onClick.accept(target);
+                               }
+                       });
+               }
+
+               @Override
+               public IResourceStream getMarkupResourceStream(MarkupContainer 
container,
+                       Class<?> containerClass)
+               {
+                       return new 
StringResourceStream("<html><head></head><body>" +
+                               "<div wicket:id=\"container\"><a 
wicket:id=\"link\">link</a></div>" +
+                               "</body></html>");
+               }
+       }
+}
diff --git a/wicket-extensions/src/test/js/veil-test.js 
b/wicket-extensions/src/test/js/veil-test.js
new file mode 100644
index 0000000000..57e342d822
--- /dev/null
+++ b/wicket-extensions/src/test/js/veil-test.js
@@ -0,0 +1,388 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/*global QUnit: true */
+
+Wicket.Event.add(window, 'domready', function() {
+       "use strict";
+
+       const { module, test } = QUnit;
+
+       const OPTIONS = { delay: 300, minimum: 500 };
+
+       let realClock;
+       let clock;
+
+       function fakeClock() {
+               let now = 0;
+               let nextId = 1;
+               let timers = [];
+
+               return {
+                       now: function() {
+                               return now;
+                       },
+                       setTimeout: function(fn, delay) {
+                               const id = nextId++;
+                               timers.push({ id: id, at: now + delay, fn: fn 
});
+                               return id;
+                       },
+                       clearTimeout: function(id) {
+                               timers = timers.filter(function(timer) {
+                                       return timer.id !== id;
+                               });
+                       },
+                       tick: function(ms) {
+                               const until = now + ms;
+                               for (;;) {
+                                       const due = 
timers.filter(function(timer) {
+                                               return timer.at <= until;
+                                       }).sort(function(a, b) {
+                                               return a.at - b.at;
+                                       })[0];
+                                       if (!due) {
+                                               break;
+                                       }
+                                       timers.splice(timers.indexOf(due), 1);
+                                       now = due.at;
+                                       due.fn();
+                               }
+                               now = until;
+                       }
+               };
+       }
+
+       function send(attrs) {
+               Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_BEFORE_SEND, 
attrs, {}, {});
+               return attrs;
+       }
+
+       function done(attrs) {
+               Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
+       }
+
+       function push(message) {
+               Wicket.Event.publish('/websocket/message', message);
+       }
+
+       function veils() {
+               return document.querySelectorAll('.wicket-veil');
+       }
+
+       function veilOf(element) {
+               return Array.prototype.filter.call(element.children, 
function(child) {
+                       return child.classList.contains('wicket-veil');
+               })[0];
+       }
+
+       function isBusy(veil) {
+               return veil.classList.contains('wicket-veil-busy');
+       }
+
+       module("Wicket.Veil", {
+               beforeEach: function() {
+                       Wicket.Veil._reset();
+                       realClock = Wicket.Veil._clock;
+                       clock = fakeClock();
+                       Wicket.Veil._clock = clock;
+               },
+               afterEach: function() {
+                       Wicket.Veil._reset();
+                       Wicket.Veil._clock = realClock;
+               }
+       });
+
+       test("without a registered veil a request is not veiled", assert => {
+               const attrs = send({ c: 'veilPageLink' });
+
+               assert.equal(veils().length, 0, "a veil appeared although none 
is registered");
+               done(attrs);
+       });
+
+       test("the page veil appears at once, transparent, and gets the spinner 
after the delay", assert => {
+               Wicket.Veil.page(OPTIONS);
+
+               const attrs = send({ c: 'veilPageLink' });
+               const veil = veilOf(document.body);
+               assert.ok(veil, "the page veil was not appended to the body");
+               assert.notOk(isBusy(veil), "the spinner showed before the 
delay");
+
+               clock.tick(299);
+               assert.notOk(isBusy(veil), "the spinner showed before the 
delay");
+
+               clock.tick(1);
+               assert.ok(isBusy(veil), "the spinner did not show after the 
delay");
+
+               done(attrs);
+       });
+
+       test("a request finishing before the delay removes the veil at once, 
without a spinner", assert => {
+               Wicket.Veil.page(OPTIONS);
+
+               const attrs = send({ c: 'veilPageLink' });
+               const veil = veilOf(document.body);
+               clock.tick(100);
+               done(attrs);
+
+               assert.equal(veils().length, 0, "the veil stayed after the 
request finished");
+               clock.tick(1000);
+               assert.notOk(isBusy(veil), "the spinner showed after the 
request finished");
+       });
+
+       test("once shown, the spinner stays for the minimum time", assert => {
+               Wicket.Veil.page(OPTIONS);
+
+               const attrs = send({ c: 'veilPageLink' });
+               clock.tick(350);
+               done(attrs);
+
+               assert.equal(veils().length, 1, "the spinner was removed before 
its minimum time");
+               clock.tick(449);
+               assert.equal(veils().length, 1, "the spinner was removed before 
its minimum time");
+               clock.tick(1);
+               assert.equal(veils().length, 0, "the spinner stayed beyond its 
minimum time");
+       });
+
+       test("a spinner shown longer than the minimum time is removed at once", 
assert => {
+               Wicket.Veil.page(OPTIONS);
+
+               const attrs = send({ c: 'veilPageLink' });
+               clock.tick(900);
+               done(attrs);
+
+               assert.equal(veils().length, 0, "the veil stayed after the 
request finished");
+       });
+
+       test("the configured timings are honoured", assert => {
+               Wicket.Veil.page({ delay: 50, minimum: 1000 });
+
+               const attrs = send({ c: 'veilPageLink' });
+               clock.tick(50);
+               assert.ok(isBusy(veilOf(document.body)), "the spinner did not 
show after the delay");
+
+               done(attrs);
+               clock.tick(999);
+               assert.equal(veils().length, 1, "the spinner was removed before 
its minimum time");
+               clock.tick(1);
+               assert.equal(veils().length, 0, "the spinner stayed beyond its 
minimum time");
+       });
+
+       test("concurrent requests share one veil, removed when the last one 
finishes", assert => {
+               Wicket.Veil.page(OPTIONS);
+
+               const first = send({ c: 'veilPageLink' });
+               const second = send({ c: 'veilPageLink' });
+               assert.equal(veils().length, 1, "each request got its own 
veil");
+
+               done(first);
+               assert.equal(veils().length, 1, "the veil was removed while a 
request was still running");
+
+               done(second);
+               assert.equal(veils().length, 0, "the veil stayed after the last 
request finished");
+       });
+
+       test("a request during the minimum time keeps the spinner up", assert 
=> {
+               Wicket.Veil.page(OPTIONS);
+
+               const first = send({ c: 'veilPageLink' });
+               clock.tick(400);
+               done(first);
+               const veil = veilOf(document.body);
+
+               const second = send({ c: 'veilPageLink' });
+               clock.tick(1000);
+               assert.equal(veilOf(document.body), veil, "the running spinner 
was replaced");
+               assert.ok(isBusy(veil), "the running spinner was hidden");
+
+               done(second);
+               assert.equal(veils().length, 0, "the veil stayed after the last 
request finished");
+       });
+
+       test("a request with the wicket_nb extra parameter is not veiled", 
assert => {
+               Wicket.Veil.page(OPTIONS);
+               Wicket.Veil.local('veilOuter', OPTIONS);
+
+               const pageRequest = send({ c: 'veilPageLink', ep: [{ name: 
'wicket_nb', value: 'true' }] });
+               const localRequest = send({ c: 'veilOuterLink', ep: { 
wicket_nb: 'true' } });
+               assert.equal(veils().length, 0, "an opted-out request was 
veiled");
+
+               done(pageRequest);
+               done(localRequest);
+               assert.equal(veils().length, 0, "finishing an opted-out request 
added a veil");
+       });
+
+       test("a request from inside a local veil veils only that component", 
assert => {
+               Wicket.Veil.page(OPTIONS);
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               const outer = document.getElementById('veilOuter');
+
+               const attrs = send({ c: 'veilOuterLink' });
+               assert.ok(veilOf(outer), "the local veil was not appended to 
its component");
+               assert.ok(outer.classList.contains('wicket-veil-host'), "the 
component is not marked as host");
+               assert.notOk(veilOf(document.body), "the page was veiled too");
+               assert.equal(veils().length, 1, "more than one veil appeared");
+
+               done(attrs);
+               assert.equal(veils().length, 0, "the local veil stayed after 
the request finished");
+               assert.notOk(outer.classList.contains('wicket-veil-host'), "the 
host class stayed");
+       });
+
+       test("a local veil shows the spinner after its delay and keeps it for 
its minimum time", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               const outer = document.getElementById('veilOuter');
+
+               const attrs = send({ c: 'veilOuterLink' });
+               const veil = veilOf(outer);
+               clock.tick(299);
+               assert.notOk(isBusy(veil), "the spinner showed before the 
delay");
+               clock.tick(1);
+               assert.ok(isBusy(veil), "the spinner did not show after the 
delay");
+
+               done(attrs);
+               clock.tick(499);
+               assert.ok(veilOf(outer), "the spinner was removed before its 
minimum time");
+               clock.tick(1);
+               assert.notOk(veilOf(outer), "the spinner stayed beyond its 
minimum time");
+       });
+
+       test("nested local veils keep their own timings", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               Wicket.Veil.local('veilInner', { delay: 0, minimum: 1000 });
+               const outer = document.getElementById('veilOuter');
+               const inner = document.getElementById('veilInner');
+
+               const innerRequest = send({ c: 'veilInnerLink' });
+               clock.tick(0);
+               assert.ok(isBusy(veilOf(inner)), "the inner spinner did not 
show at once");
+               done(innerRequest);
+               clock.tick(999);
+               assert.ok(veilOf(inner), "the inner spinner was removed before 
its minimum time");
+               clock.tick(1);
+               assert.notOk(veilOf(inner), "the inner spinner stayed beyond 
its minimum time");
+
+               const outerRequest = send({ c: 'veilOuterLink' });
+               clock.tick(299);
+               assert.notOk(isBusy(veilOf(outer)), "the outer spinner used the 
inner delay");
+               clock.tick(1);
+               assert.ok(isBusy(veilOf(outer)), "the outer spinner did not 
show after its delay");
+               done(outerRequest);
+       });
+
+       test("nested local veils: the innermost takes the request", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               Wicket.Veil.local('veilInner', OPTIONS);
+
+               const attrs = send({ c: 'veilInnerLink' });
+               assert.ok(veilOf(document.getElementById('veilInner')), "the 
inner component was not veiled");
+               assert.equal(veils().length, 1, "more than the innermost 
component was veiled");
+
+               done(attrs);
+       });
+
+       test("a request from outside a local veil falls back to the page veil", 
assert => {
+               Wicket.Veil.page(OPTIONS);
+               Wicket.Veil.local('veilInner', OPTIONS);
+
+               const attrs = send({ c: 'veilOuterLink' });
+               assert.ok(veilOf(document.body), "the page was not veiled");
+               assert.notOk(veilOf(document.getElementById('veilInner')), "an 
unrelated component was veiled");
+
+               done(attrs);
+       });
+
+       test("a component replaced by an Ajax update is veiled again once 
re-registered", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               const old = document.getElementById('veilOuter');
+               const replacement = old.cloneNode(true);
+               old.parentNode.replaceChild(replacement, old);
+               Wicket.Veil.local('veilOuter', OPTIONS);
+
+               const attrs = send({ c: 'veilOuterLink' });
+               assert.ok(veilOf(replacement), "the replacement component was 
not veiled");
+
+               done(attrs);
+               assert.equal(veils().length, 0, "the local veil stayed after 
the request finished");
+       });
+
+       test("a pushed veil message raises a local veil, with its timings, 
until it is hidden", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               const outer = document.getElementById('veilOuter');
+
+               push('{"wicketVeil":"show","id":"veilOuter"}');
+               const veil = veilOf(outer);
+               assert.ok(veil, "the push did not raise the local veil");
+               assert.ok(outer.classList.contains('wicket-veil-host'), "the 
component is not marked as host");
+               clock.tick(299);
+               assert.notOk(isBusy(veil), "the spinner showed before the 
delay");
+               clock.tick(1);
+               assert.ok(isBusy(veil), "the spinner did not show after the 
delay");
+
+               Wicket.Veil.hide('veilOuter');
+               clock.tick(499);
+               assert.ok(veilOf(outer), "the spinner was removed before its 
minimum time");
+               clock.tick(1);
+               assert.equal(veils().length, 0, "the veil stayed after it was 
hidden");
+       });
+
+       test("a pushed unveil message lowers the veil", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+
+               push('{"wicketVeil":"show","id":"veilOuter"}');
+               push('{"wicketVeil":"hide","id":"veilOuter"}');
+
+               assert.equal(veils().length, 0, "the unveil message did not 
lower the veil");
+       });
+
+       test("a veil whose component is replaced by the pushed update stays for 
the spinner's minimum time", assert => {
+               Wicket.Veil.local('veilOuter', OPTIONS);
+
+               push('{"wicketVeil":"show","id":"veilOuter"}');
+               clock.tick(400);
+               const old = document.getElementById('veilOuter');
+               const replacement = old.cloneNode(false);
+               replacement.className = '';
+               old.parentNode.replaceChild(replacement, old);
+               Wicket.Veil.local('veilOuter', OPTIONS);
+               Wicket.Veil.hide('veilOuter');
+
+               const veil = veilOf(replacement);
+               assert.ok(veil, "the veil did not move onto the new element");
+               assert.ok(isBusy(veil), "the moved veil lost its spinner");
+               assert.ok(replacement.classList.contains('wicket-veil-host'), 
"the new element is not marked as host");
+               clock.tick(399);
+               assert.ok(veilOf(replacement), "the spinner was removed before 
its minimum time");
+               clock.tick(1);
+               assert.equal(veils().length, 0, "the veil stayed beyond the 
spinner's minimum time");
+               
assert.notOk(replacement.classList.contains('wicket-veil-host'), "the host 
class stayed");
+       });
+
+       test("other WebSocket messages, unknown ids and unmatched hides are 
ignored", assert => {
+               Wicket.Veil.page(OPTIONS);
+               Wicket.Veil.local('veilOuter', OPTIONS);
+
+               push('hello');
+               push('{"wicketVeil":');
+               push('{"wicketVeil":"show","id":"veilGone"}');
+               Wicket.Veil.hide('veilOuter');
+               assert.equal(veils().length, 0, "a veil appeared");
+
+               const attrs = send({ c: 'veilOuterLink' });
+               assert.ok(veilOf(document.getElementById('veilOuter')),
+                       "an unmatched hide broke the next request's veil");
+               done(attrs);
+       });
+});
diff --git a/wicket-extensions/src/test/js/veil.html 
b/wicket-extensions/src/test/js/veil.html
new file mode 100644
index 0000000000..f9deee8f02
--- /dev/null
+++ b/wicket-extensions/src/test/js/veil.html
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You 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.
+-->
+<html>
+
+<head>
+       <title id="titleId">Wicket.Veil JavaScript tests</title>
+       <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+       <link rel="stylesheet" href="/wicket-core/src/test/js/qunit/qunit.css" 
type="text/css" media="screen" />
+</head>
+
+<body>
+       <div id="qunit"></div>
+
+       <div id="qunit-fixture">
+               <a id="veilPageLink" href="#page">page</a>
+               <div id="veilOuter">
+                       <a id="veilOuterLink" href="#outer">outer</a>
+                       <div id="veilInner">
+                               <a id="veilInnerLink" href="#inner">inner</a>
+                       </div>
+               </div>
+       </div>
+
+       <script>
+               // version lies between question mark and first ampersand (or 
end).
+               // 'vanilla' (veil.html?vanilla) selects the jQuery-free 
wicket-ajax.js
+               // engine instead of the default JQuery + wicket-ajax-jquery.js 
combination
+               // (veil.html?4.0.0, veil.html?3.7.1, ...).
+               var version = location.search.match(/\?(.*?)(&|$)/)[1];
+               var useVanilla = (version === 'vanilla');
+
+               if (!useVanilla) {
+                       document.write("<scr"+"ipt 
src='/wicket-core/src/main/java/org/apache/wicket/resource/jquery/jquery-"+version+".js'></scr"+"ipt>");
+               }
+       </script>
+       <script>
+               if (!useVanilla) {
+                       document.write("<scr"+"ipt 
src='/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js'></scr"+"ipt>");
+               } else {
+                       document.write("<scr"+"ipt 
src='/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js'></scr"+"ipt>");
+               }
+       </script>
+       <script type="text/javascript" 
src="/wicket-core/src/test/js/qunit/qunit.js"></script>
+
+       <!-- the module under test -->
+       <script type="text/javascript" 
src="/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js"></script>
+
+       <script type="text/javascript" src="veil-test.js"></script>
+</body>
+</html>
diff --git a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc 
b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
new file mode 100644
index 0000000000..74f9383789
--- /dev/null
+++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
@@ -0,0 +1,104 @@
+
+An activity indicator tells the user that an AJAX request is running, but it 
does not stop them from clicking again while it runs. A second click on a slow 
button can send the same request twice, and a click elsewhere can act on a page 
the pending response is about to replace. Package 
_org.apache.wicket.extensions.ajax.veil_ addresses this with two behaviors that 
put a veil over the page, or over a part of it, for the duration of an AJAX 
request.
+
+The veil appears as soon as a request is sent. It is transparent and swallows 
mouse clicks, so a request that is over quickly does not make the page flash. 
Only if the request is still running after a delay (300 ms by default) does the 
veil dim its region and show a spinner. Once shown, the spinner stays for a 
minimum time (500 ms by default), so a response that arrives just after it 
appeared does not make it flicker. The veil does not intercept the keyboard.
+
+=== Veiling the whole page
+
+_PageVeilBehavior_ veils the whole page during every AJAX request fired from 
it. It can only be added to a page, typically a base page shared by the whole 
application:
+
+[source,java]
+----
+public class BasePage extends WebPage {
+       public BasePage() {
+               add(new PageVeilBehavior());
+       }
+}
+----
+
+Adding it to any other component throws an _IllegalArgumentException_.
+
+=== Veiling a single component
+
+_LocalVeilBehavior_ veils only the component it is added to, and only during 
the AJAX requests fired by that component or by a component nested in it. The 
rest of the page stays usable:
+
+[source,java]
+----
+WebMarkupContainer searchPanel = new WebMarkupContainer("search");
+searchPanel.add(new LocalVeilBehavior());
+----
+
+A request fired from inside a component with a local veil does not veil the 
page, even when the page has a _PageVeilBehavior_. When local veils are nested, 
the innermost one takes the request. The behavior makes the component output 
its markup id, and it appends the veil to the component's element, so the 
component has to render an element that can hold a _div_.
+
+=== Veiling a component during a WebSocket push
+
+A component updated through a WebSocket push is recomputed on the server, 
without any AJAX request the browser could notice. _LocalVeilBehavior_ lets the 
server raise the veil itself: _getVeilMessage()_ returns a text message that 
raises the veil when it is sent through a WebSocket connection of the 
component's page, and _unveil(IPartialPageRequestHandler)_ lowers it once the 
pushed update has been applied. The spinner follows the same timings as for an 
AJAX request.
+
+[source,java]
+----
+LocalVeilBehavior veil = new LocalVeilBehavior();
+counterPanel.add(veil);
+
+// while handling a request, e.g. the click that schedules the work
+String veilMessage = veil.getVeilMessage();
+
+// in the background thread, when the work starts
+connection.sendMessage(veilMessage);
+// ... compute ...
+connection.sendMessage(new CounterUpdate(value));
+
+// in onEvent(), for the WebSocketPushPayload carrying the CounterUpdate
+handler.add(counterPanel);
+veil.unveil(handler);
+----
+
+_getVeilMessage()_ reads the component's markup id, so like any component 
access it is called while handling a request; the message is a plain string 
that can then be sent from any thread. When the work fails and no update 
follows, send _getUnveilMessage()_ instead. If the update replaces the 
component while its spinner is still within its minimum time, the veil moves 
onto the new element and stays for the rest of that time.
+
+=== Leaving a request unveiled
+
+Every AJAX request is veiled, including the ones the user did not start, such 
as the requests of an _AbstractAjaxTimerBehavior_ or of a lazy loading panel. A 
request that should leave the page usable opts out with the static method 
_PageVeilBehavior.noVeil(AjaxRequestAttributes)_, called from the 
_updateAjaxAttributes_ method of its component or behavior:
+
+[source,java]
+----
+add(new AjaxLink<Void>("refresh") {
+       @Override
+       protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
+               super.updateAjaxAttributes(attributes);
+               PageVeilBehavior.noVeil(attributes);
+       }
+
+       @Override
+       public void onClick(AjaxRequestTarget target) {
+               // ...
+       }
+});
+----
+
+_noVeil_ adds the extra parameter `wicket_nb` 
(`PageVeilBehavior.NO_VEIL_PARAMETER`) to the request. A request carrying it is 
left alone by the page veil and by every local veil, and the parameter reaches 
the server as well.
+
+=== Timings and appearance
+
+Both timings can be set on each behavior, for the page veil and for local 
veils alike, with _setSpinnerDelay(Duration)_ and 
_setMinimumSpinnerTime(Duration)_. A delay of _Duration.ZERO_ shows the spinner 
as soon as the request is sent. Nested local veils keep their own timings, so a 
part of the page that is known to be slow can show its spinner sooner than the 
rest:
+
+[source,java]
+----
+WebMarkupContainer outer = new WebMarkupContainer("outer");
+outer.add(new LocalVeilBehavior());
+add(outer);
+
+WebMarkupContainer inner = new WebMarkupContainer("inner");
+inner.add(new LocalVeilBehavior()
+       .setSpinnerDelay(Duration.ofMillis(100))
+       .setMinimumSpinnerTime(Duration.ofSeconds(1)));
+outer.add(inner);
+----
+
+A request fired from inside _inner_ veils only _inner_, with its timings; one 
fired from elsewhere in _outer_ veils _outer_, inner panel included. Subclasses 
can also override _getSpinnerDelay()_ and _getMinimumSpinnerTime()_, both 
declared on the common base class _AbstractVeilBehavior_.
+
+The look comes from the stylesheet _wicket-veil.css_, contributed by the 
behaviors, and can be overridden with the following CSS classes:
+
+* _wicket-veil_: the veil itself, a _div_ covering the page (_position: 
fixed_) or the component (_position: absolute_).
+* _wicket-veil-busy_: added to the veil once the spinner delay has passed. It 
gives the veil its background, and its `::after` pseudo-element draws the 
spinner.
+* _wicket-veil-host_: added to the element of a component with a local veil 
while it is veiled. It gives the element _position: relative_, so the veil 
covers exactly that element.
+
+On the client side the veil is implemented by _Wicket.Veil_, which subscribes 
to the global AJAX topics '/ajax/call/beforeSend' and '/ajax/call/done' listed 
among the global AJAX call listeners later in this chapter. It works with the 
JQuery-based and with the plain JavaScript AJAX implementation alike.
diff --git a/wicket-user-guide/src/main/asciidoc/single.adoc 
b/wicket-user-guide/src/main/asciidoc/single.adoc
index 347547e44d..11c5056aa6 100644
--- a/wicket-user-guide/src/main/asciidoc/single.adoc
+++ b/wicket-user-guide/src/main/asciidoc/single.adoc
@@ -567,6 +567,10 @@ include::ajax/ajax_3.adoc[leveloffset=+1]
 
 include::ajax/ajax_4.adoc[leveloffset=+1]
 
+=== Blocking the page while an AJAX request is running
+
+include::ajax/ajax_12.adoc[leveloffset=+1]
+
 === AJAX request attributes and call listeners
 
 include::ajax/ajax_5.adoc[leveloffset=+1]

Reply via email to