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));