You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/03 14:50:10 UTC
(juneau) branch master updated: Add first-class client-side async-status loading affordance (WORK-J0505): renderAsyncStatus(container,state,message) primitive + neutral .juneau-view-loading-region CSS mount, and refactor the detail-panel loading/error/empty/ok lifecycle onto it (increments 1 and 2).
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 0bbb119dbc Add first-class client-side async-status loading affordance (WORK-J0505): renderAsyncStatus(container,state,message) primitive + neutral .juneau-view-loading-region CSS mount, and refactor the detail-panel loading/error/empty/ok lifecycle onto it (increments 1 and 2).
0bbb119dbc is described below
commit 0bbb119dbc163d6e746bea2feafe8442d6318893
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Sep 3 10:50:02 2026 -0400
Add first-class client-side async-status loading affordance (WORK-J0505): renderAsyncStatus(container,state,message) primitive + neutral .juneau-view-loading-region CSS mount, and refactor the detail-panel loading/error/empty/ok lifecycle onto it (increments 1 and 2).
---
.../org/apache/juneau/views/juneau-views.css | 55 ++++++++++++++++++++++
.../org/apache/juneau/views/juneau-views.js | 50 +++++++++++++++++---
.../rest/server/views/ViewsJs_RowDetail_Test.java | 39 +++++++++++++++
.../src/test/js/row-detail.cjs | 43 +++++++++++++++++
4 files changed, 180 insertions(+), 7 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index 3ad6273567..db46c7a507 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -1424,6 +1424,61 @@ table.dataTable thead .sorting_desc_disabled:after {
margin: 0.5em 0 0;
}
+/*
+ * Empty consumer-owned mount for an async-status indicator placed below a ribbon or subtab bar. The shared
+ * control-height and type steps give the region chrome-adjacent dimensions without declaring another scale step;
+ * paint inherits currentColor unless a consumer supplies either role token.
+ */
+.juneau-view-loading-region {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ min-height: var(--jc-chrome-control-height);
+ padding: 0 var(--jc-chrome-control-padding-x);
+ font-size: var(--jc-chrome-font-size-2);
+ color: var(--jc-loading-region-color, currentColor);
+ border-bottom: 1px solid var(--jc-loading-region-border, currentColor);
+}
+
+/*
+ * Async-status loading spinner. A widget-internal proportion sized off its own text, not a
+ * --jc-chrome-glyph-size step - the same carve-out the scale's own header comment gives a pill dot or toggle
+ * knob, because this glyph is not an SVG icon role shared across surfaces. NO palette color: the ring is drawn
+ * with `currentColor` so it inherits whatever text color the status paragraph already has, consistent with this
+ * file's dependency-free/neutral contract.
+ *
+ * juneau-views.js appends this span only for the `loading` state; `error` and `empty` replace the status
+ * paragraph's content with plain text, and `ok` removes the status paragraph entirely.
+ */
+.juneau-view-spinner {
+ display: inline-block;
+ width: 0.9em;
+ height: 0.9em;
+ margin-right: 0.5em;
+ vertical-align: -0.15em;
+ border: 0.15em solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: juneau-view-spin 0.75s linear infinite;
+}
+
+@keyframes juneau-view-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/*
+ * Motion-sensitive users get a static ring instead of an infinite spin - the loading state is still visible
+ * (and still announced via the paragraph's role="status"), it just doesn't animate.
+ */
+@media (prefers-reduced-motion: reduce) {
+ .juneau-view-spinner {
+ animation: none;
+ border-right-color: currentColor;
+ }
+}
+
/*
* Nested read-only table inside a row-detail section (a table[data-juneau-view] + its id-less sidecar, appended
* after the section's fields grid). NEUTRAL - layout only, no palette color. Full-width within the panel; the
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 44148752a9..789ce57db2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -137,6 +137,7 @@
// Per-cell opt-out from the `.juneau-view-table td` clip/ellipsis default (see ViewTable.CELL_WRAP_CLASS). Used
// by the named emitters' `class` facets and by any cell whose content is a panel rather than a line of text.
const CELL_WRAP_CLASS = "juneau-cell-wrap";
+ const ASYNC_STATUS_ATTR = "data-juneau-async-status";
const NS = window.JuneauViews = window.JuneauViews || {};
NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
@@ -1332,6 +1333,43 @@
el.firstChild.remove();
}
+ /**
+ * Renders this runtime's one owned async-status paragraph in {@code container}. The success state removes the
+ * paragraph, leaving caller-owned success content untouched.
+ */
+ function renderAsyncStatus(container, state, message) {
+ if (!container || typeof container.appendChild !== "function") return null;
+ let status = null;
+ for (const child of container.childNodes || []) {
+ if (child.nodeType === 1 && child.tagName === "P"
+ && child.getAttribute(ASYNC_STATUS_ATTR) !== null && child.getAttribute("role") === "status") {
+ status = child;
+ break;
+ }
+ }
+ if (state === "ok") {
+ if (status?.parentNode) status.parentNode.removeChild(status);
+ return null;
+ }
+ if (!status) {
+ status = document.createElement("p");
+ status.setAttribute(ASYNC_STATUS_ATTR, "");
+ status.setAttribute("role", "status");
+ container.appendChild(status);
+ }
+ clearElementChildren(status);
+ if (state === "loading") {
+ const spinner = document.createElement("span");
+ spinner.className = "juneau-view-spinner";
+ spinner.setAttribute("aria-hidden", "true");
+ status.appendChild(spinner);
+ status.appendChild(document.createTextNode(message == null ? "Loading…" : String(message)));
+ } else {
+ status.textContent = message;
+ }
+ return status;
+ }
+
function copyAllowedMarkdownAttrs(from, to) {
if (!from || !to || typeof to.setAttribute !== "function") return;
if (from.tagName === "A") {
@@ -2800,10 +2838,7 @@
if (panel.dataset.juneauDetailState !== "ok") return;
activateNestedTablesInPane(pane, rowId);
});
- const loading = document.createElement("p");
- loading.className = "juneau-view-detail-status";
- loading.textContent = "Loading…";
- panel.appendChild(loading);
+ renderAsyncStatus(panel, "loading").className = "juneau-view-detail-status";
row.child(panel).show();
// DataTables wraps the panel in a plain <td colspan> that is a descendant of this .juneau-view-table, so the
// table's clip/ellipsis cell default would otherwise flatten the whole expanded panel into one nowrap line.
@@ -2828,7 +2863,7 @@
if (!stillCurrent()) { settleMap(); return; }
hideActionRefs(panel);
panel.dataset.juneauDetailState = kind;
- loading.textContent = message;
+ renderAsyncStatus(panel, kind, message);
settleMap();
}
@@ -2877,7 +2912,7 @@
// Layered on top of the lifecycle gate above, never competing with it: this pass only ever disables.
applyActionRefRules(panel, body.fields);
panel.dataset.juneauDetailState = "ok";
- if (loading.parentNode) loading.remove();
+ renderAsyncStatus(panel, "ok");
// Now that the parent detail loaded (2xx + contract OK), init the nested tables that live in a currently
// VISIBLE pane (in tab mode, only the initially-selected section is visible; a hidden pane's nested table
// waits for its tab to be activated via the onActivate callback above). Each nested table runs its OWN
@@ -5492,7 +5527,8 @@
readBulkDef: readBulkDef,
buildBulkToolbar: buildBulkToolbar,
executeBulkAction: executeBulkAction,
- renderInlineError: renderInlineError
+ renderInlineError: renderInlineError,
+ renderAsyncStatus: renderAsyncStatus
};
if (document.readyState === "loading") {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
index adc9e7e3be..9ed6c1561f 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
@@ -49,6 +49,13 @@ class ViewsJs_RowDetail_Test extends TestBase {
}
}
+ private static String viewsCss() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.VIEWS_CSS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + ViewsMixin.VIEWS_CSS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
@Test void a01_helpersExportedOnNsInit() throws Exception {
var body = viewsJs();
for (var name : new String[]{
@@ -65,6 +72,7 @@ class ViewsJs_RowDetail_Test extends TestBase {
"detailTabTargetIndex: detailTabTargetIndex",
"activateDetailTab: activateDetailTab",
"buildDetailStrip: buildDetailStrip",
+ "renderAsyncStatus: renderAsyncStatus",
"JUNEAU_ROW_DETAIL_CONTRACT_VERSION: JUNEAU_ROW_DETAIL_CONTRACT_VERSION"
})
assertTrue(body.contains(name), () -> "missing export '" + name + "'");
@@ -74,6 +82,19 @@ class ViewsJs_RowDetail_Test extends TestBase {
"write path must target the expanded DataTables row, not expand JSON");
}
+ @Test void a02_loadingRegionUsesTheSharedScaleWithoutDeclaringChromeTokens() throws Exception {
+ var css = viewsCss();
+ var start = css.indexOf(".juneau-view-loading-region {");
+ assertTrue(start >= 0, css);
+ var rule = css.substring(start, css.indexOf("}", start));
+ assertTrue(rule.contains("min-height: var(--jc-chrome-control-height)"), rule);
+ assertTrue(rule.contains("padding: 0 var(--jc-chrome-control-padding-x)"), rule);
+ assertTrue(rule.contains("font-size: var(--jc-chrome-font-size-2)"), rule);
+ assertTrue(rule.contains("currentColor"), rule);
+ assertFalse(rule.matches("(?s).*--jc-chrome-[\\w-]+\\s*:.*"), rule);
+ assertFalse(rule.contains("#"), rule);
+ }
+
private static Map<?,?> report;
@BeforeAll
@@ -228,6 +249,24 @@ class ViewsJs_RowDetail_Test extends TestBase {
assertEquals(true, r.get("icon_unknownHidden"));
}
+ @Test void b20_asyncStatusRendersAllStatesWithoutTouchingConsumerContent() {
+ var r = report();
+ assertEquals(true, r.get("hasRenderAsyncStatus"));
+ assertEquals(true, r.get("async_defaultLoadingText"));
+ assertEquals(true, r.get("async_statusRole"));
+ assertEquals(true, r.get("async_marker"));
+ assertEquals(true, r.get("async_spinnerDecorative"));
+ assertEquals(true, r.get("async_loadingOverride"));
+ assertEquals(true, r.get("async_noDuplicateLoading"));
+ assertEquals(true, r.get("async_errorText"));
+ assertEquals(true, r.get("async_errorHasNoSpinner"));
+ assertEquals(true, r.get("async_emptyText"));
+ assertEquals(true, r.get("async_emptyHasNoSpinner"));
+ assertEquals(true, r.get("async_okRemovesStatus"));
+ assertEquals(true, r.get("async_preservesConsumerContent"));
+ assertEquals(true, r.get("async_noDuplicateAfterRefetch"));
+ }
+
@Test void b07_fillRenderSlot_tagProgressLinkedAndCanary() {
var r = report();
assertEquals(true, r.get("hasFillRender"));
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
index aa62cceadc..76686845ad 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
@@ -156,6 +156,7 @@ function el(tag) {
removeChild: function (c) {
const i = this.childNodes.indexOf(c);
if (i >= 0) this.childNodes.splice(i, 1);
+ c.parentNode = null;
return c;
},
replaceChildren: function () { this.childNodes.length = 0; },
@@ -1023,4 +1024,46 @@ if (out.hasApplyActionRefRules) {
out.dfa_passingRuleStaysEnabled = dfaPassing.btn.disabled === false;
}
+// ----------------------------------------------------------------------------------------------------------------
+// Async status - a consumer-owned container keeps its content while this primitive creates, updates, and removes
+// only its marked direct-child status paragraph.
+// ----------------------------------------------------------------------------------------------------------------
+
+out.hasRenderAsyncStatus = typeof I.renderAsyncStatus === 'function';
+if (out.hasRenderAsyncStatus) {
+ const asyncContainer = el('div');
+ const consumerContent = el('strong');
+ consumerContent.textContent = 'Existing content';
+ asyncContainer.appendChild(consumerContent);
+
+ I.renderAsyncStatus(asyncContainer, 'loading');
+ const loading = asyncContainer.childNodes[1];
+ out.async_defaultLoadingText = loading.textContent === 'Loading…';
+ out.async_statusRole = loading.getAttribute('role') === 'status';
+ out.async_marker = loading.getAttribute('data-juneau-async-status') === '';
+ out.async_spinnerDecorative = loading.firstChild.className === 'juneau-view-spinner'
+ && loading.firstChild.getAttribute('aria-hidden') === 'true';
+
+ I.renderAsyncStatus(asyncContainer, 'loading', 'Refreshing…');
+ out.async_loadingOverride = loading.textContent === 'Refreshing…';
+ out.async_noDuplicateLoading = asyncContainer.childNodes.length === 2 && asyncContainer.childNodes[1] === loading;
+
+ I.renderAsyncStatus(asyncContainer, 'error', 'Request failed');
+ out.async_errorText = loading.textContent === 'Request failed';
+ out.async_errorHasNoSpinner = loading.childNodes.length === 0;
+
+ I.renderAsyncStatus(asyncContainer, 'empty', 'No results');
+ out.async_emptyText = loading.textContent === 'No results';
+ out.async_emptyHasNoSpinner = loading.childNodes.length === 0;
+
+ I.renderAsyncStatus(asyncContainer, 'ok');
+ out.async_okRemovesStatus = asyncContainer.childNodes.length === 1;
+ out.async_preservesConsumerContent = asyncContainer.firstChild === consumerContent
+ && consumerContent.textContent === 'Existing content';
+
+ I.renderAsyncStatus(asyncContainer, 'loading');
+ I.renderAsyncStatus(asyncContainer, 'loading');
+ out.async_noDuplicateAfterRefetch = asyncContainer.childNodes.length === 2;
+}
+
process.stdout.write(JSON.stringify(out));