You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/30 13:50:18 UTC
(causeway) 03/05: feat(webcomponents): enhance action presentation
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 50f6928cd98385dc8d781d0e6217d797c956fff8
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun Aug 30 15:27:52 2026 +0200
feat(webcomponents): enhance action presentation
---
.../changes/enhance-action-presentation/tasks.md | 34 ++++----
.../domain/rich/query/RichMemberMetadata.java | 22 ++++++
.../domain/rich/query/RichMemberMetadataTest.java | 50 ++++++++++++
.../viewer/test/domain/dept/Department.java | 5 +-
.../test/e2e/special/MemberMetadata_IntegTest.java | 12 +++
...preserves_resource_metadata_and_redaction._.gql | 8 ++
viewers/webcomponents/foundation/USAGE.adoc | 11 ++-
.../foundation/src/action-element.mjs | 55 ++++++++++---
.../foundation/src/action-presentation.mjs | 91 ++++++++++++++++++++++
.../webcomponents/foundation/src/action-widget.mjs | 23 ++++--
.../foundation/src/component-styles.css | 27 ++++++-
.../foundation/src/component-styles.mjs | 27 ++++++-
.../src/interaction-controller-element.mjs | 25 ++++--
.../webcomponents/foundation/src/menu-layout.mjs | 10 ++-
.../foundation/src/menubar-element.mjs | 61 ++++++++++++---
.../foundation/src/menubar-projection.mjs | 9 ++-
.../foundation/src/menubar-widget.mjs | 16 +++-
.../foundation/src/object-context-controller.mjs | 7 +-
.../foundation/test/action-presentation.test.mjs | 46 +++++++++++
.../foundation/test/action-widget.test.mjs | 2 +-
.../foundation/test/component-styles.test.mjs | 4 +-
.../foundation/test/components.test.mjs | 43 ++++++++++
.../test/fixtures/menu-layout-fixtures.mjs | 3 +-
.../test/fixtures/rich-schema-fixture.mjs | 14 +++-
.../foundation/test/interaction.test.mjs | 8 +-
.../foundation/test/menubar-component.test.mjs | 6 +-
.../foundation/test/object-context.test.mjs | 37 +++++++++
viewers/webcomponents/htmx/README.adoc | 2 +
viewers/webcomponents/htmx/pom.xml | 5 ++
.../webcomponents/htmx/HtmxPageRenderer.java | 4 +
.../htmx/HtmxViewerControllerTest.java | 5 +-
.../sample-htmx-petclinic/README.adoc | 2 +
.../sample/htmx/petclinic/domain/PetOwner.java | 29 ++++++-
.../sample/htmx/petclinic/domain/PetOwners.java | 7 +-
.../webcomponents/pages/petclinic.PetOwner.html | 6 +-
.../src/main/resources/menubars.layout.xml | 10 ++-
.../petclinic/PetClinicHtmxPlaywrightTest.java | 56 +++++++++++--
.../PetClinicHtmxApplication_IntegTest.java | 43 +++++++++-
38 files changed, 733 insertions(+), 92 deletions(-)
diff --git a/openspec/changes/enhance-action-presentation/tasks.md b/openspec/changes/enhance-action-presentation/tasks.md
index 3ebe0110385..df9ae5638e2 100644
--- a/openspec/changes/enhance-action-presentation/tasks.md
+++ b/openspec/changes/enhance-action-presentation/tasks.md
@@ -1,34 +1,34 @@
## 1. Rich Action Metadata
-- [ ] 1.1 Add nullable static Font Awesome class and position fields to rich member metadata with action-only side-effect-free extraction.
-- [ ] 1.2 Extend GraphQL model and integration tests and approvals for present, absent, and non-action icon metadata.
+- [x] 1.1 Add nullable static Font Awesome class and position fields to rich member metadata with action-only side-effect-free extraction.
+- [x] 1.2 Extend GraphQL model and integration tests and approvals for present, absent, and non-action icon metadata.
## 2. Action Discovery and Shared Presentation
-- [ ] 2.1 Extend action discovery and preparation to select available canonical name, description, icon classes, and icon position compatibly with older schemas.
-- [ ] 2.2 Add bounded reusable action tooltip and Font Awesome token and position projection helpers with focused unit tests.
+- [x] 2.1 Extend action discovery and preparation to select available canonical name, description, icon classes, and icon position compatibly with older schemas.
+- [x] 2.2 Add bounded reusable action tooltip and Font Awesome token and position projection helpers with focused unit tests.
## 3. Ordinary Action Controls and Prompts
-- [ ] 3.1 Add observed reflected `named` support and effective naming precedence to `<cw-action>` while preserving `label` compatibility.
-- [ ] 3.2 Render composed description and disabled-reason tooltips and positioned decorative icons in native and Vaadin ordinary action controls.
-- [ ] 3.3 Preserve effective action presentation through preparation and render parameterized dialog headings and quiet accessible descriptions.
-- [ ] 3.4 Extend component, interaction, widget, accessibility, focus, failure, and compatibility tests for action presentation.
+- [x] 3.1 Add observed reflected `named` support and effective naming precedence to `<cw-action>` while preserving `label` compatibility.
+- [x] 3.2 Render composed description and disabled-reason tooltips and positioned decorative icons in native and Vaadin ordinary action controls.
+- [x] 3.3 Preserve effective action presentation through preparation and render parameterized dialog headings and quiet accessible descriptions.
+- [x] 3.4 Extend component, interaction, widget, accessibility, focus, failure, and compatibility tests for action presentation.
## 4. Application Menu Actions
-- [ ] 4.1 Preserve bounded Font Awesome position and canonical action description through effective-menu parsing, action state, and immutable projection.
-- [ ] 4.2 Render equivalent composed tooltips and positioned icons in native and Vaadin-backed menu items.
-- [ ] 4.3 Extend menu parsing, projection, widget, keyboard, disabled, generation, and native-fallback tests.
+- [x] 4.1 Preserve bounded Font Awesome position and canonical action description through effective-menu parsing, action state, and immutable projection.
+- [x] 4.2 Render equivalent composed tooltips and positioned icons in native and Vaadin-backed menu items.
+- [x] 4.3 Extend menu parsing, projection, widget, keyboard, disabled, generation, and native-fallback tests.
## 5. HTMX Assets and Petclinic Demonstration
-- [ ] 5.1 Add the pinned Font Awesome WebJar stylesheet to the generic HTMX shell with rendering, packaging, CSP, and same-origin tests.
-- [ ] 5.2 Add representative Petclinic action names, descriptions, disabled reasons, parameters, and left and right icons while retaining unselected controls.
-- [ ] 5.3 Extend Petclinic integration and default and native Playwright journeys for buttons, menus, tooltips, prompts, icons, focus, and invocation.
-- [ ] 5.4 Update action and HTMX usage documentation with naming precedence and presentation examples.
+- [x] 5.1 Add the pinned Font Awesome WebJar stylesheet to the generic HTMX shell with rendering, packaging, CSP, and same-origin tests.
+- [x] 5.2 Add representative Petclinic action names, descriptions, disabled reasons, parameters, and left and right icons while retaining unselected controls.
+- [x] 5.3 Extend Petclinic integration and default and native Playwright journeys for buttons, menus, tooltips, prompts, icons, focus, and invocation.
+- [x] 5.4 Update action and HTMX usage documentation with naming precedence and presentation examples.
## 6. Validation
-- [ ] 6.1 Run focused foundation, GraphQL model and integration, HTMX, Petclinic, default and native Playwright, RAT, CSP, and packaging checks.
-- [ ] 6.2 Run strict OpenSpec validation and `git diff --check`, then review the final diff for bounded metadata and backward compatibility.
+- [x] 6.1 Run focused foundation, GraphQL model and integration, HTMX, Petclinic, default and native Playwright, RAT, CSP, and packaging checks.
+- [x] 6.2 Run strict OpenSpec validation and `git diff --check`, then review the final diff for bounded metadata and backward compatibility.
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadata.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadata.java
index 1961610c178..69e61ec9ca9 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadata.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadata.java
@@ -27,6 +27,9 @@
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLObjectType;
+import org.apache.causeway.applib.fa.FontAwesomeLayers;
+import org.apache.causeway.core.metamodel.facets.members.iconfa.FaFacet;
+import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectFeature;
import org.apache.causeway.core.metamodel.util.Facets;
import org.apache.causeway.viewer.graphql.model.context.Context;
@@ -65,6 +68,10 @@ private static GraphQLObjectType metadataType(final Context context) {
GraphQLNonNull.nonNull(Scalars.GraphQLString)))
.field(field("description", "Canonical translated description, or null.",
Scalars.GraphQLString))
+ .field(field("cssClassFa", "Static action Font Awesome quick notation, or null.",
+ Scalars.GraphQLString))
+ .field(field("cssClassFaPosition", "Static action Font Awesome LEFT or RIGHT position, or null.",
+ Scalars.GraphQLString))
.field(field("maxLength", "Finite maximum input length, or null.",
Scalars.GraphQLInt))
.field(field("pattern", "Java regular-expression text, or null.",
@@ -98,6 +105,11 @@ protected Map<String, Object> fetchData(final DataFetchingEnvironment environmen
var values = new LinkedHashMap<String, Object>();
values.put("friendlyName", feature.getCanonicalFriendlyName());
values.put("description", feature.getCanonicalDescription().orElse(null));
+ var staticIcon = staticActionIcon(feature);
+ values.put("cssClassFa", staticIcon == null ? null : staticIcon.toQuickNotation());
+ values.put("cssClassFaPosition", staticIcon == null || staticIcon.position() == null
+ ? null
+ : staticIcon.position().name());
values.put("maxLength", includeEditorConstraints
? RichScalarMetadataField.finiteMaxLength(feature)
: null);
@@ -116,4 +128,14 @@ protected Map<String, Object> fetchData(final DataFetchingEnvironment environmen
: null);
return values;
}
+
+ private static FontAwesomeLayers staticActionIcon(final ObjectFeature feature) {
+ if (!(feature instanceof ObjectAction action)) {
+ return null;
+ }
+ return action.lookupFacet(FaFacet.class)
+ .flatMap(facet -> facet.getSpecialization().left())
+ .map(staticFacet -> staticFacet.getLayers())
+ .orElse(null);
+ }
}
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadataTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadataTest.java
index ac7167f48d6..51e9e5d7d20 100644
--- a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadataTest.java
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/rich/query/RichMemberMetadataTest.java
@@ -27,11 +27,17 @@
import org.mockito.Answers;
import org.springframework.context.i18n.LocaleContextHolder;
+import org.apache.causeway.applib.fa.FontAwesomeLayers;
+import org.apache.causeway.applib.layout.component.CssClassFaPosition;
import org.apache.causeway.applib.services.bookmark.BookmarkService;
import org.apache.causeway.applib.services.registry.ServiceRegistry;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.core.config.environment.CausewaySystemEnvironment;
+import org.apache.causeway.core.metamodel.facets.members.iconfa.FaFacet;
+import org.apache.causeway.core.metamodel.facets.members.iconfa.FaImperativeFacet;
+import org.apache.causeway.core.metamodel.facets.members.iconfa.FaStaticFacet;
import org.apache.causeway.core.metamodel.objectmanager.ObjectManager;
+import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectFeature;
import org.apache.causeway.core.metamodel.specloader.SpecificationLoader;
import org.apache.causeway.viewer.graphql.model.application.ApplicationEntryService;
@@ -78,6 +84,50 @@ void usesOneSharedTypeAndResolvesCanonicalTextOnEveryRequest() {
}
}
+ @Test
+ void exposesOnlyStaticActionFontAwesomeMetadata() {
+ var action = mock(ObjectAction.class);
+ when(action.getCanonicalFriendlyName()).thenReturn("Place order");
+ when(action.getCanonicalDescription()).thenReturn(Optional.empty());
+ var faFacet = mock(FaFacet.class);
+ var staticFacet = mock(FaStaticFacet.class);
+ when(staticFacet.getLayers()).thenReturn(FontAwesomeLayers
+ .fromQuickNotation("fa-cart-shopping")
+ .withPosition(CssClassFaPosition.RIGHT));
+ when(faFacet.getSpecialization()).thenReturn(org.apache.causeway.commons.functional.Either.left(staticFacet));
+ when(action.lookupFacet(FaFacet.class)).thenReturn(Optional.of(faFacet));
+
+ assertThat(new RichMemberMetadata(context(), action, false).fetchData(null))
+ .containsEntry("cssClassFa", "cart-shopping")
+ .containsEntry("cssClassFaPosition", "RIGHT");
+
+ var actionWithoutFacet = mock(ObjectAction.class);
+ when(actionWithoutFacet.getCanonicalFriendlyName()).thenReturn("No icon");
+ when(actionWithoutFacet.getCanonicalDescription()).thenReturn(Optional.empty());
+ when(actionWithoutFacet.lookupFacet(FaFacet.class)).thenReturn(Optional.empty());
+ assertThat(new RichMemberMetadata(context(), actionWithoutFacet, false).fetchData(null))
+ .containsEntry("cssClassFa", null)
+ .containsEntry("cssClassFaPosition", null);
+
+ var imperativeAction = mock(ObjectAction.class);
+ when(imperativeAction.getCanonicalFriendlyName()).thenReturn("Dynamic icon");
+ when(imperativeAction.getCanonicalDescription()).thenReturn(Optional.empty());
+ var imperativeFacet = mock(FaFacet.class);
+ when(imperativeFacet.getSpecialization()).thenReturn(
+ org.apache.causeway.commons.functional.Either.right(mock(FaImperativeFacet.class)));
+ when(imperativeAction.lookupFacet(FaFacet.class)).thenReturn(Optional.of(imperativeFacet));
+ assertThat(new RichMemberMetadata(context(), imperativeAction, false).fetchData(null))
+ .containsEntry("cssClassFa", null)
+ .containsEntry("cssClassFaPosition", null);
+
+ var ordinaryFeature = mock(ObjectFeature.class);
+ when(ordinaryFeature.getCanonicalFriendlyName()).thenReturn("Name");
+ when(ordinaryFeature.getCanonicalDescription()).thenReturn(Optional.empty());
+ assertThat(new RichMemberMetadata(context(), ordinaryFeature, false).fetchData(null))
+ .containsEntry("cssClassFa", null)
+ .containsEntry("cssClassFaPosition", null);
+ }
+
private static Context context() {
@SuppressWarnings("unchecked")
Provider<Context> contextProvider = mock(Provider.class);
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/Department.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/Department.java
index 73f436b5fdc..a33bc72f4ec 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/Department.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/Department.java
@@ -42,6 +42,7 @@
import org.apache.causeway.applib.annotation.ParameterLayout;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.layout.component.CssClassFaPosition;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -81,7 +82,9 @@ public Department(String name, DeptHead deptHead) {
@Action(semantics = SemanticsOf.IDEMPOTENT)
@ActionLayout(
named = "Rename department",
- describedAs = "Changes the department display name")
+ describedAs = "Changes the department display name",
+ cssClassFa = "pen-to-square",
+ cssClassFaPosition = CssClassFaPosition.RIGHT)
public class changeName {
public Department act(
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
index c1ef926a312..8ab15db449a 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.java
@@ -53,6 +53,8 @@ void targeted_introspection_exposes_only_the_shared_local_shape() throws Excepti
.containsExactlyInAnyOrder(
"friendlyName",
"description",
+ "cssClassFa",
+ "cssClassFaPosition",
"maxLength",
"pattern",
"patternFlags",
@@ -121,8 +123,14 @@ void reads_known_wrappers_and_preserves_resource_metadata_and_redaction() throws
.isEqualTo("Rename department");
assertThat(action.get("metadata").get("description").stringValue())
.isEqualTo("Changes the department display name");
+ assertThat(action.get("metadata").get("cssClassFa").stringValue())
+ .isEqualTo("pen-to-square");
+ assertThat(action.get("metadata").get("cssClassFaPosition").stringValue())
+ .isEqualTo("RIGHT");
assertThat(parameter.get("friendlyName").stringValue()).isEqualTo("Replacement name");
assertThat(parameter.get("description").stringValue()).isEqualTo("New name for the department");
+ assertThat(parameter.get("cssClassFa").isNull()).isTrue();
+ assertThat(parameter.get("cssClassFaPosition").isNull()).isTrue();
assertThat(parameter.get("maxLength").intValue()).isEqualTo(50);
assertThat(parameter.get("pattern").stringValue()).isEqualTo("[A-Za-z !]+");
assertThat(parameter.get("patternFlags").intValue()).isEqualTo(2);
@@ -132,10 +140,14 @@ void reads_known_wrappers_and_preserves_resource_metadata_and_redaction() throws
assertThat(collection.get("friendlyName").stringValue()).isEqualTo("Department staff");
assertThat(collection.get("description").stringValue())
.isEqualTo("Staff assigned to this department");
+ assertThat(collection.get("cssClassFa").isNull()).isTrue();
+ assertThat(collection.get("cssClassFaPosition").isNull()).isTrue();
assertThat(collection.get("maxLength").isNull()).isTrue();
assertThat(property.get("friendlyName").stringValue()).isEqualTo("Staff display name");
assertThat(property.get("description").stringValue()).isEqualTo("Name shown for the staff member");
+ assertThat(property.get("cssClassFa").isNull()).isTrue();
+ assertThat(property.get("cssClassFaPosition").isNull()).isTrue();
assertThat(property.get("maxLength").intValue()).isEqualTo(40);
assertThat(property.get("pattern").stringValue()).isEqualTo("[A-Za-z .!'-]+");
assertThat(property.get("patternFlags").intValue()).isEqualTo(2);
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.reads_known_wrappers_and_preserves_resource_metadata_and_redaction._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.reads_known_wrappers_and_preserves_resource_metadata_and_redaction._.gql
index 6e2d0b9f148..53a5068ac2f 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.reads_known_wrappers_and_preserves_resource_metadata_and_redaction._.gql
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.reads_known_wrappers_and_preserves_resource_metadata_and_redaction._.gql
@@ -5,6 +5,8 @@
metadata {
friendlyName
description
+ cssClassFa
+ cssClassFaPosition
maxLength
}
}
@@ -12,12 +14,16 @@
metadata {
friendlyName
description
+ cssClassFa
+ cssClassFaPosition
}
params {
newName {
metadata {
friendlyName
description
+ cssClassFa
+ cssClassFaPosition
maxLength
pattern
patternFlags
@@ -36,6 +42,8 @@
metadata {
friendlyName
description
+ cssClassFa
+ cssClassFaPosition
maxLength
pattern
patternFlags
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 94e3523ea86..6c123c2dc94 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -58,7 +58,9 @@ Applications upgrading across this breaking change must replace each former open
Semantic `causeway-*` events, CSS classes, `data-causeway-*` attributes, `--causeway-*` variables, and `/causeway-*` asset paths keep their existing spelling.
Properties, actions, collections, and collection columns use the standard `id` attribute and native reflected `id` property to identify their Causeway member.
-Properties, actions, and collection columns accept an optional `label` override.
+Properties and collection columns accept an optional `label` override.
+Actions accept `named` and the existing compatible `label` override, with `named` taking precedence over `label`, canonical `metadata.friendlyName`, and the humanized action ID.
+For example, `<cw-action id="placeOrder" named="Place a new order"></cw-action>` changes presentation only and continues to invoke the semantic `placeOrder` action.
Collections accept `named` and `described-as` heading overrides; the existing `label` name override remains compatible, with `named` taking precedence.
The former `member` attribute and custom `member` property are not compatibility aliases.
Applications must keep authored IDs unique according to ordinary HTML identifier rules.
@@ -67,6 +69,9 @@ Object links use `logical-type`, `object-id`, `title`, and `disabled`.
Collections use `active` and declarative collection-column children.
Without HTML heading overrides, collections use canonical `metadata.friendlyName` and `metadata.description`, followed by a humanized member-id name and no-description fallback.
Collection-level disabled or unmodifiable reasons are not presented as explanatory text or tooltips; individual cells and associated actions retain their own usability semantics.
+Actions use canonical metadata descriptions as bounded accessible tooltips and as introductory text in parameter prompts.
+When an action is disabled, its tooltip retains the description and adds the current disabled reason as a separate section.
+Static Font Awesome action metadata is rendered decoratively on the configured left or right side after conservative class-token validation; missing, dynamic, or malformed hints render no icon.
The corresponding JavaScript properties support programmatic composition.
A property or collection accepts a `rendererRegistry`.
@@ -224,12 +229,14 @@ The menu parser accepts the Causeway bootstrap menu-bars namespace and the docum
It shares the object-grid parser's one-mebibyte input limit, depth limit of 64, element limit of 4,096, and diagnostic limit of 20.
Document types, entity declarations and references, CDATA, non-XML processing instructions, scripts, styles, executable attributes, malformed nesting, wrong namespaces, and oversized content are rejected.
Resource markup is never inserted into the page.
-Labels and descriptions are escaped text, while CSS and icon hints are exposed only as inert documented `data-*` attributes.
+Labels and descriptions are escaped text, while CSS hints remain inert documented `data-*` attributes.
+Static Font Awesome action hints are token-bounded and rendered decoratively on the effective left or right side.
Entries identify rich service-action wrappers by public logical service type and semantic action ID.
Current hidden and disabled state is grouped into one read per logical service type for a generation rather than one operation per menu entry.
Hidden actions expose no entry or presentation metadata.
Disabled actions remain visible and counted, cannot be invoked, and associate their established reason accessibly.
+Action descriptions provide accessible tooltips for native and Vaadin controls, and disabled reasons are composed as a separate tooltip section rather than replacing those descriptions.
Empty sections, menus, and bars collapse after current state has been applied.
Service-bound adapters reuse the standard parameter editors, defaults, choices, autocomplete, validation, prompt, cancellation, stale-response, and typed-result behavior.
diff --git a/viewers/webcomponents/foundation/src/action-element.mjs b/viewers/webcomponents/foundation/src/action-element.mjs
index 7728634e338..e3664e25180 100644
--- a/viewers/webcomponents/foundation/src/action-element.mjs
+++ b/viewers/webcomponents/foundation/src/action-element.mjs
@@ -23,6 +23,10 @@ import {
renderNativeCausewayActionButton,
useCausewayActionWidget
} from './action-widget.mjs';
+import {
+ composeActionTooltip,
+ normalizeActionPresentation
+} from './action-presentation.mjs';
import {CausewaySemanticEvent} from './component-contracts.mjs';
import {CausewayContextConsumerElement} from './context-consumer-element.mjs';
import {createSemanticEvent} from './context-events.mjs';
@@ -32,7 +36,7 @@ let actionSequence = 0;
export class CausewayActionElement extends CausewayContextConsumerElement {
static get observedAttributes() {
- return ['id', 'label', 'data-testid'];
+ return ['id', 'named', 'label', 'data-testid'];
}
constructor() {
@@ -65,12 +69,22 @@ export class CausewayActionElement extends CausewayContextConsumerElement {
super.disconnectedCallback();
}
+ get named() {
+ return this.getAttribute('named') || '';
+ }
+
+ set named(value) {
+ if (value == null) this.removeAttribute('named');
+ else this.setAttribute('named', value);
+ }
+
get label() {
return this.getAttribute('label') || '';
}
set label(value) {
- this.setAttribute('label', value);
+ if (value == null) this.removeAttribute('label');
+ else this.setAttribute('label', value);
}
attributeChangedCallback(name, oldValue, newValue) {
@@ -102,7 +116,8 @@ export class CausewayActionElement extends CausewayContextConsumerElement {
Object.freeze({
actionId: this.id,
identity: context?.identity ?? null,
- context
+ context,
+ presentation: this.actionPresentation(state)
}),
{cancelable: true}
));
@@ -112,11 +127,9 @@ export class CausewayActionElement extends CausewayContextConsumerElement {
if (!state) {
return;
}
- const label = this.label || humanize(this.id);
- const candidateDescription = state.descriptor?.description || '';
- const description = candidateDescription.trim().toLocaleLowerCase() === label.trim().toLocaleLowerCase()
- ? ''
- : candidateDescription;
+ const presentation = this.actionPresentation(state);
+ const label = presentation.name;
+ const description = presentation.description;
const descriptionMarkup = description
? `<span id="${this.descriptionId}" class="causeway-action-description">${escapeHtml(description)}</span>`
: '';
@@ -142,17 +155,37 @@ export class CausewayActionElement extends CausewayContextConsumerElement {
const describedBy = [description ? this.descriptionId : '', disabledReason ? this.reasonId : '']
.filter(Boolean)
.join(' ');
+ const tooltip = composeActionTooltip(description, disabledReason);
const testId = this.getAttribute('data-testid');
- const control = {label, describedBy, disabled: Boolean(disabledReason), testId: testId ? `${testId}-control` : ''};
+ const control = {
+ label,
+ describedBy,
+ disabled: Boolean(disabledReason),
+ testId: testId ? `${testId}-control` : '',
+ icon: presentation.icon
+ };
const controlMarkup = useCausewayActionWidget()
? renderCausewayActionWidget(control)
: renderNativeCausewayActionButton(control);
+ const tooltipAttributes = tooltip
+ ? ` class="causeway-action-control-tooltip" data-tooltip="${escapeHtml(tooltip)}"${disabledReason ? ' tabindex="0"' : ''}`
+ : '';
this.innerHTML = `<div class="causeway-action${disabledReason ? ' causeway-disabled' : ''}">
- ${controlMarkup}
+ <span${tooltipAttributes}>${controlMarkup}</span>
${descriptionMarkup}
- ${disabledReason ? `<span id="${this.reasonId}" class="causeway-action-disabled-reason">${escapeHtml(disabledReason)}</span>` : ''}
+ ${disabledReason ? `<span id="${this.reasonId}" class="causeway-action-disabled-reason causeway-visually-hidden">${escapeHtml(disabledReason)}</span>` : ''}
</div>`;
}
+
+ actionPresentation(state = this.componentState) {
+ const metadata = state?.data?.metadata ?? {};
+ return normalizeActionPresentation({
+ name: this.named || this.label || metadata.friendlyName || humanize(this.id),
+ description: metadata.description || state?.descriptor?.description || '',
+ cssClassFa: metadata.cssClassFa,
+ cssClassFaPosition: metadata.cssClassFaPosition
+ });
+ }
}
function originatesFromOrdinaryActionControl(action, target) {
diff --git a/viewers/webcomponents/foundation/src/action-presentation.mjs b/viewers/webcomponents/foundation/src/action-presentation.mjs
new file mode 100644
index 00000000000..356abd44ad5
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/action-presentation.mjs
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ * You may obtain a copy of the License at
+ *
+ * https://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.
+ */
+
+import {composeMemberTooltip} from './description-presentation.mjs';
+import {escapeHtml} from './rendering.mjs';
+
+const MAX_ACTION_NAME = 512;
+const MAX_ACTION_DESCRIPTION = 2_048;
+const MAX_ICON_NOTATION = 256;
+const FONT_AWESOME_TOKEN = /^(?:fa-)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const STYLE_TOKENS = new Set([
+ 'fa-brands', 'fa-duotone', 'fa-light', 'fa-regular', 'fa-sharp',
+ 'fa-sharp-duotone', 'fa-solid', 'fa-thin'
+]);
+
+export function normalizeActionPresentation(value = {}) {
+ const name = boundedText(value.name ?? value.friendlyName, MAX_ACTION_NAME);
+ const description = boundedText(value.description, MAX_ACTION_DESCRIPTION);
+ return Object.freeze({
+ name,
+ description: normalizedDistinctDescription(description, name),
+ icon: normalizeFontAwesomeIcon(value.cssClassFa, value.cssClassFaPosition)
+ });
+}
+
+export function composeActionTooltip(description, disabledReason) {
+ return composeMemberTooltip(description, disabledReason);
+}
+
+export function normalizeFontAwesomeIcon(value, position) {
+ const notation = String(value ?? '').trim().slice(0, MAX_ICON_NOTATION);
+ if (!notation) return null;
+ const tokens = notation.split(/\s+/)
+ .filter(Boolean)
+ .map(token => token.startsWith('fa-') ? token.toLowerCase() : `fa-${token.toLowerCase()}`);
+ if (tokens.length === 0 || tokens.some(token => !FONT_AWESOME_TOKEN.test(token))) return null;
+ if (!tokens.some(token => STYLE_TOKENS.has(token))) tokens.unshift('fa-solid');
+ return Object.freeze({
+ classes: Object.freeze([...new Set(tokens)]),
+ position: String(position ?? 'LEFT').toUpperCase() === 'RIGHT' ? 'RIGHT' : 'LEFT'
+ });
+}
+
+export function renderActionContent(label, icon) {
+ const text = `<span class="causeway-action-label">${escapeHtml(label ?? '')}</span>`;
+ if (!icon) return text;
+ const iconMarkup = `<i class="causeway-action-icon ${icon.classes.map(escapeHtml).join(' ')}" aria-hidden="true"></i>`;
+ return icon.position === 'RIGHT' ? `${text}${iconMarkup}` : `${iconMarkup}${text}`;
+}
+
+export function appendActionContent(control, label, icon) {
+ control.replaceChildren();
+ if (icon && globalThis.document?.createElement) {
+ const iconElement = document.createElement('i');
+ iconElement.className = `causeway-action-icon ${icon.classes.join(' ')}`;
+ iconElement.setAttribute('aria-hidden', 'true');
+ if (icon.position !== 'RIGHT') control.appendChild(iconElement);
+ appendText(control, label);
+ if (icon.position === 'RIGHT') control.appendChild(iconElement);
+ return;
+ }
+ control.textContent = label ?? '';
+}
+
+function appendText(control, label) {
+ const span = document.createElement('span');
+ span.className = 'causeway-action-label';
+ span.textContent = label ?? '';
+ control.appendChild(span);
+}
+
+function normalizedDistinctDescription(description, name) {
+ return description && description.toLocaleLowerCase() !== name.toLocaleLowerCase() ? description : '';
+}
+
+function boundedText(value, maximum) {
+ return String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maximum);
+}
diff --git a/viewers/webcomponents/foundation/src/action-widget.mjs b/viewers/webcomponents/foundation/src/action-widget.mjs
index 1304e55ed81..693610012d4 100644
--- a/viewers/webcomponents/foundation/src/action-widget.mjs
+++ b/viewers/webcomponents/foundation/src/action-widget.mjs
@@ -6,6 +6,7 @@
* to you under the Apache License, Version 2.0.
*/
+import {appendActionContent, renderActionContent} from './action-presentation.mjs';
import {escapeHtml} from './rendering.mjs';
export const CAUSEWAY_ACTION_CONTROL = 'cw-action-control';
@@ -41,7 +42,7 @@ export function useCausewayActionWidget() {
return configuration.enabled && !failed;
}
-export function renderCausewayActionWidget({label, describedBy = '', disabled = false, testId = ''} = {}) {
+export function renderCausewayActionWidget({label, describedBy = '', disabled = false, testId = '', icon = null} = {}) {
const attributes = [
`data-label="${escapeHtml(label ?? '')}"`,
'data-causeway-action-control'
@@ -49,12 +50,16 @@ export function renderCausewayActionWidget({label, describedBy = '', disabled =
if (describedBy) attributes.push(`data-describedby="${escapeHtml(describedBy)}"`);
if (testId) attributes.push(`data-control-testid="${escapeHtml(testId)}"`);
if (disabled) attributes.push('disabled');
- const native = nativeButtonMarkup(label, describedBy, disabled, testId);
+ if (icon) {
+ attributes.push(`data-icon-classes="${escapeHtml(icon.classes.join(' '))}"`);
+ attributes.push(`data-icon-position="${escapeHtml(icon.position)}"`);
+ }
+ const native = nativeButtonMarkup(label, describedBy, disabled, testId, icon);
return `<${CAUSEWAY_ACTION_CONTROL} ${attributes.join(' ')}>${native}</${CAUSEWAY_ACTION_CONTROL}>`;
}
-export function renderNativeCausewayActionButton({label, describedBy = '', disabled = false, testId = ''} = {}) {
- return nativeButtonMarkup(label, describedBy, disabled, testId);
+export function renderNativeCausewayActionButton({label, describedBy = '', disabled = false, testId = '', icon = null} = {}) {
+ return nativeButtonMarkup(label, describedBy, disabled, testId, icon);
}
export class CausewayActionControlElement extends HTMLElement {
@@ -94,7 +99,11 @@ export class CausewayActionControlElement extends HTMLElement {
await globalThis.customElements.whenDefined('vaadin-button');
if (!this.isConnected || generation !== this._generation || revision !== configurationRevision) return;
const control = document.createElement('vaadin-button');
- control.textContent = this.dataset.label ?? '';
+ const iconClasses = String(this.dataset.iconClasses ?? '').split(/\s+/).filter(Boolean);
+ const icon = iconClasses.length > 0
+ ? Object.freeze({classes: Object.freeze(iconClasses), position: this.dataset.iconPosition === 'RIGHT' ? 'RIGHT' : 'LEFT'})
+ : null;
+ appendActionContent(control, this.dataset.label ?? '', icon);
control.setAttribute('theme', 'primary');
control.disabled = this.hasAttribute('disabled');
if (this.dataset.describedby) control.setAttribute('aria-describedby', this.dataset.describedby);
@@ -121,8 +130,8 @@ export class CausewayActionControlElement extends HTMLElement {
}
}
-function nativeButtonMarkup(label, describedBy, disabled, testId) {
- return `<button type="button" data-causeway-action-control${disabled ? ' disabled aria-disabled="true"' : ''}${describedBy ? ` aria-describedby="${escapeHtml(describedBy)}"` : ''}${testId ? ` data-testid="${escapeHtml(testId)}"` : ''}>${escapeHtml(label ?? '')}</button>`;
+function nativeButtonMarkup(label, describedBy, disabled, testId, icon) {
+ return `<button type="button" data-causeway-action-control${disabled ? ' disabled aria-disabled="true"' : ''}${describedBy ? ` aria-describedby="${escapeHtml(describedBy)}"` : ''}${testId ? ` data-testid="${escapeHtml(testId)}"` : ''}>${renderActionContent(label, icon)}</button>`;
}
function announcePolicyChange(detail) {
diff --git a/viewers/webcomponents/foundation/src/component-styles.css b/viewers/webcomponents/foundation/src/component-styles.css
index afca5e363ef..2e47ddc1cce 100644
--- a/viewers/webcomponents/foundation/src/component-styles.css
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -373,11 +373,13 @@ cw-field-editor[data-widget-state="fallback"]::after {
transform: translateY(0);
visibility: visible;
}
-.causeway-member-tooltip {
+.causeway-member-tooltip,
+.causeway-action-control-tooltip {
cursor: help;
position: relative;
}
-.causeway-member-tooltip::after {
+.causeway-member-tooltip::after,
+.causeway-action-control-tooltip::after {
background: CanvasText;
border-radius: 0.25rem;
color: Canvas;
@@ -399,11 +401,30 @@ cw-field-editor[data-widget-state="fallback"]::after {
z-index: 20;
}
.causeway-member-tooltip:hover::after,
-.causeway-member-tooltip:focus-visible::after {
+.causeway-member-tooltip:focus-visible::after,
+.causeway-action-control-tooltip:hover::after,
+.causeway-action-control-tooltip:focus-visible::after,
+.causeway-action-control-tooltip:focus-within::after {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
+.causeway-action [data-causeway-action-control],
+.causeway-service-action-control,
+.causeway-menubar-item-label {
+ align-items: center;
+ display: inline-flex;
+ gap: var(--causeway-action-icon-gap, 0.4rem);
+}
+.causeway-action-icon {
+ flex: 0 0 auto;
+}
+.causeway-action-prompt-description {
+ color: var(--causeway-muted, GrayText);
+ font-size: var(--causeway-action-description-font-size, 0.82em);
+ line-height: 1.35;
+ margin: 0;
+}
.causeway-breadcrumbs-list {
align-items: center;
display: flex;
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index 7a19edd0ec3..e6db7400c77 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -374,11 +374,13 @@ cw-field-editor[data-widget-state="fallback"]::after {
transform: translateY(0);
visibility: visible;
}
-.causeway-member-tooltip {
+.causeway-member-tooltip,
+.causeway-action-control-tooltip {
cursor: help;
position: relative;
}
-.causeway-member-tooltip::after {
+.causeway-member-tooltip::after,
+.causeway-action-control-tooltip::after {
background: CanvasText;
border-radius: 0.25rem;
color: Canvas;
@@ -400,11 +402,30 @@ cw-field-editor[data-widget-state="fallback"]::after {
z-index: 20;
}
.causeway-member-tooltip:hover::after,
-.causeway-member-tooltip:focus-visible::after {
+.causeway-member-tooltip:focus-visible::after,
+.causeway-action-control-tooltip:hover::after,
+.causeway-action-control-tooltip:focus-visible::after,
+.causeway-action-control-tooltip:focus-within::after {
opacity: 1;
transform: translateY(0);
visibility: visible;
}
+.causeway-action [data-causeway-action-control],
+.causeway-service-action-control,
+.causeway-menubar-item-label {
+ align-items: center;
+ display: inline-flex;
+ gap: var(--causeway-action-icon-gap, 0.4rem);
+}
+.causeway-action-icon {
+ flex: 0 0 auto;
+}
+.causeway-action-prompt-description {
+ color: var(--causeway-muted, GrayText);
+ font-size: var(--causeway-action-description-font-size, 0.82em);
+ line-height: 1.35;
+ margin: 0;
+}
.causeway-breadcrumbs-list {
align-items: center;
display: flex;
diff --git a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
index 4bd8bc34eb1..bbbb7f09a63 100644
--- a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
@@ -17,6 +17,7 @@
* under the License.
*/
+import {normalizeActionPresentation} from './action-presentation.mjs';
import {CausewaySemanticEvent} from './component-contracts.mjs';
import {createSemanticEvent} from './context-events.mjs';
import {defaultEditorRegistry, parseCausewayEditorValue, renderCausewayEditor} from './editor-registry.mjs';
@@ -32,6 +33,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
super();
const sequence = ++controllerSequence;
this.titleId = `causeway-action-prompt-title-${sequence}`;
+ this.descriptionId = `causeway-action-prompt-description-${sequence}`;
this.errorId = `causeway-action-prompt-error-${sequence}`;
this._editorRegistry = defaultEditorRegistry;
this.promptState = null;
@@ -47,10 +49,11 @@ export class CausewayInteractionControllerElement extends HTMLElement {
this.onActionRequest = event => {
const actionId = event.detail?.actionId;
const context = event.detail?.context;
+ const presentation = event.detail?.presentation;
const source = focusRestoreTarget(event.target);
queueMicrotask(() => {
if (!event.defaultPrevented && actionId && context) {
- void this.beginAction(actionId, context, source);
+ void this.beginAction(actionId, context, source, presentation);
}
});
};
@@ -161,7 +164,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
this.generation += 1;
}
- async beginAction(actionId, context, source = null) {
+ async beginAction(actionId, context, source = null, presentation = null) {
if (this.promptState?.status === InteractionStatus.INVOKING) {
return false;
}
@@ -173,6 +176,12 @@ export class CausewayInteractionControllerElement extends HTMLElement {
actionId,
context,
source,
+ presentation: normalizeActionPresentation({
+ name: presentation?.name || humanize(actionId),
+ description: presentation?.description,
+ cssClassFa: presentation?.icon?.classes?.join(' '),
+ cssClassFaPosition: presentation?.icon?.position
+ }),
values: Object.freeze({}),
parameters: Object.freeze([]),
error: null
@@ -604,7 +613,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
return `<div class="causeway-action-prompt causeway-loading" role="status" data-testid="action-prompt">Preparing action…</div>`;
}
if (state.parameters.length === 0 && state.status === InteractionStatus.INVOKING) {
- return `<div class="causeway-action-prompt causeway-loading" role="status" data-testid="action-prompt">Invoking ${escapeHtml(humanize(state.actionId))}…</div>`;
+ return `<div class="causeway-action-prompt causeway-loading" role="status" data-testid="action-prompt">Invoking ${escapeHtml(state.presentation?.name || humanize(state.actionId))}…</div>`;
}
const parameterMarkup = state.parameters.map(parameter => this.#parameterMarkup(parameter)).join('');
const busy = [InteractionStatus.VALIDATING, InteractionStatus.INVOKING].includes(state.status);
@@ -612,9 +621,15 @@ export class CausewayInteractionControllerElement extends HTMLElement {
const errorMarkup = publicError
? `<div id="${this.errorId}" class="causeway-action-prompt-error" role="alert">${escapeHtml(publicError)}</div>`
: '';
- return `<dialog open class="causeway-action-prompt" role="dialog" aria-modal="true" aria-labelledby="${this.titleId}"${state.error ? ` aria-describedby="${this.errorId}"` : ''} data-testid="action-prompt">
+ const description = state.presentation?.description || '';
+ const descriptionMarkup = description
+ ? `<p id="${this.descriptionId}" class="causeway-action-prompt-description">${escapeHtml(description)}</p>`
+ : '';
+ const describedBy = [description ? this.descriptionId : '', state.error ? this.errorId : ''].filter(Boolean).join(' ');
+ return `<dialog open class="causeway-action-prompt" role="dialog" aria-modal="true" aria-labelledby="${this.titleId}"${describedBy ? ` aria-describedby="${describedBy}"` : ''} data-testid="action-prompt">
<form method="dialog" novalidate>
- <h2 id="${this.titleId}">${escapeHtml(humanize(state.actionId))}</h2>
+ <h2 id="${this.titleId}">${escapeHtml(state.presentation?.name || humanize(state.actionId))}</h2>
+ ${descriptionMarkup}
${parameterMarkup}
${errorMarkup}
<div class="causeway-action-prompt-actions">
diff --git a/viewers/webcomponents/foundation/src/menu-layout.mjs b/viewers/webcomponents/foundation/src/menu-layout.mjs
index 4cc8f66f5ce..e4249542496 100644
--- a/viewers/webcomponents/foundation/src/menu-layout.mjs
+++ b/viewers/webcomponents/foundation/src/menu-layout.mjs
@@ -29,7 +29,7 @@ export const MENU_BAR_ROLES = Object.freeze(['primary', 'secondary', 'tertiary']
export const MAX_MENU_DIAGNOSTICS = MAX_STRUCTURAL_DIAGNOSTICS;
const MENU_ATTRIBUTES = new Set(['cssClassFa', 'unreferencedActions']);
-const ACTION_ATTRIBUTES = new Set(['objectType', 'id', 'bookmarking', 'cssClass', 'cssClassFa', 'namedEscaped']);
+const ACTION_ATTRIBUTES = new Set(['objectType', 'id', 'bookmarking', 'cssClass', 'cssClassFa', 'cssClassFaPosition', 'namedEscaped']);
const DOCUMENT_ATTRIBUTES = new Set(['schemaLocation']);
export class CausewayMenuError extends Error {
@@ -215,6 +215,7 @@ function parseAction(node, sectionPath, index, diagnostics) {
description: boundedText(describedNode, 2_048, diagnostics, path, 'ACTION_DESCRIPTION_TOO_LONG'),
cssHint: boundedAttribute(node, 'cssClass', 256, diagnostics, path),
iconHint: boundedAttribute(node, 'cssClassFa', 256, diagnostics, path),
+ iconPosition: boundedIconPosition(node.attributes.get('cssClassFaPosition'), diagnostics, path),
disabled: null,
path
};
@@ -243,6 +244,13 @@ function boundedText(node, maximum, diagnostics, path, code) {
return value.slice(0, maximum);
}
+function boundedIconPosition(value, diagnostics, path) {
+ const position = String(value ?? 'LEFT').trim().toUpperCase();
+ if (position === 'LEFT' || position === 'RIGHT') return position;
+ diagnostics.add('UNSUPPORTED_ICON_POSITION', 'Unsupported service-action icon position was ignored.', path);
+ return 'LEFT';
+}
+
function boundedAttribute(node, name, maximum, diagnostics, path) {
const value = (node.attributes.get(name) ?? '').trim();
if (value.length <= maximum) {
diff --git a/viewers/webcomponents/foundation/src/menubar-element.mjs b/viewers/webcomponents/foundation/src/menubar-element.mjs
index af8fb68c613..94e290c18ce 100644
--- a/viewers/webcomponents/foundation/src/menubar-element.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-element.mjs
@@ -17,6 +17,11 @@
* under the License.
*/
+import {
+ composeActionTooltip,
+ normalizeActionPresentation,
+ renderActionContent
+} from './action-presentation.mjs';
import {CausewayElementName, CausewaySemanticEvent} from './component-contracts.mjs';
import {
createSemanticEvent,
@@ -246,7 +251,11 @@ export class CausewayMenubarElement extends HTMLElementBase {
control.presentation = Object.freeze({
projection,
overflowLabel: overflowLabel(globalThis.document?.documentElement?.lang),
- activate: descriptor => this.#activateServiceAction(descriptor.serviceLogicalTypeName, descriptor.actionId, control)
+ activate: descriptor => this.#activateServiceAction(
+ descriptor.serviceLogicalTypeName,
+ descriptor.actionId,
+ control,
+ descriptor)
});
this.#restoreSemanticFocus(renderRoot, control);
}
@@ -333,10 +342,14 @@ export class CausewayMenubarElement extends HTMLElementBase {
if (disclosure) {
this.#closeMenu(disclosure, {focus: true});
}
- this.#activateServiceAction(serviceLogicalTypeName, actionId, actionButton);
+ this.#activateServiceAction(
+ serviceLogicalTypeName,
+ actionId,
+ actionButton,
+ findServiceAction(this._currentBar, serviceLogicalTypeName, actionId));
}
- #activateServiceAction(serviceLogicalTypeName, actionId, origin) {
+ #activateServiceAction(serviceLogicalTypeName, actionId, origin, descriptor = null) {
if (!serviceLogicalTypeName || !actionId || !this._context) return;
const context = this._context.serviceContext(serviceLogicalTypeName);
this._semanticFocusIntent = Object.freeze({role: this.role, actionId, serviceLogicalTypeName, generation: this._currentState?.generation ?? 0});
@@ -345,7 +358,13 @@ export class CausewayMenubarElement extends HTMLElementBase {
serviceLogicalTypeName,
target: context.interactionTarget,
identity: null,
- context
+ context,
+ presentation: normalizeActionPresentation({
+ name: descriptor?.label || humanize(actionId),
+ description: descriptor?.description,
+ cssClassFa: descriptor?.iconHint,
+ cssClassFaPosition: descriptor?.iconPosition
+ })
}), {cancelable: true}));
}
@@ -517,16 +536,29 @@ function renderSection(section, sequence, role, menuIndex, sectionIndex) {
}
function renderAction(action, sequence, role, menuIndex, sectionIndex, actionIndex) {
- const label = action.label || humanize(action.actionId);
+ const presentation = normalizeActionPresentation({
+ name: action.label || humanize(action.actionId),
+ description: action.description,
+ cssClassFa: action.iconHint,
+ cssClassFaPosition: action.iconPosition
+ });
+ const descriptionId = `causeway-service-action-description-${sequence}-${role}-${menuIndex}-${sectionIndex}-${actionIndex}`;
const reasonId = `causeway-service-action-reason-${sequence}-${role}-${menuIndex}-${sectionIndex}-${actionIndex}`;
- const describedBy = action.disabled ? ` aria-describedby="${reasonId}"` : '';
+ const describedByIds = [presentation.description ? descriptionId : '', action.disabled ? reasonId : ''].filter(Boolean);
+ const describedBy = describedByIds.length ? ` aria-describedby="${describedByIds.join(' ')}"` : '';
const disabled = action.disabled ? ' disabled aria-disabled="true"' : '';
- const title = action.description ? ` title="${escapeHtml(action.description)}"` : '';
+ const tooltip = composeActionTooltip(presentation.description, action.disabled);
+ const tooltipAttributes = tooltip
+ ? ` data-tooltip="${escapeHtml(tooltip)}"${action.disabled ? ' tabindex="0"' : ''}`
+ : '';
+ const description = presentation.description
+ ? `<span class="causeway-action-description causeway-visually-hidden" id="${descriptionId}">${escapeHtml(presentation.description)}</span>`
+ : '';
const reason = action.disabled
? `<span class="causeway-disabled-reason causeway-visually-hidden" id="${reasonId}">${escapeHtml(action.disabled)}</span>`
: '';
- return `<div class="causeway-service-action" data-causeway-service-action-region${dataHint('css-hint', action.cssHint)}${dataHint('icon-hint', action.iconHint)}>
- <button class="causeway-service-action-control" type="button" data-causeway-service-action data-service-logical-type="${escapeHtml(action.serviceLogicalTypeName)}" data-action-id="${escapeHtml(action.actionId)}"${disabled}${describedBy}${title}>${renderIcon(action.iconHint)}${escapeHtml(label)}</button>${reason}
+ return `<div class="causeway-service-action${tooltip ? ' causeway-action-control-tooltip' : ''}" data-causeway-service-action-region${dataHint('css-hint', action.cssHint)}${dataHint('icon-hint', action.iconHint)}${tooltipAttributes}>
+ <button class="causeway-service-action-control" type="button" data-causeway-service-action data-service-logical-type="${escapeHtml(action.serviceLogicalTypeName)}" data-action-id="${escapeHtml(action.actionId)}"${disabled}${describedBy}>${renderActionContent(presentation.name, presentation.icon)}</button>${description}${reason}
</div>`;
}
@@ -540,6 +572,17 @@ function dataHint(name, value) {
return value ? ` data-${name}="${escapeHtml(value)}"` : '';
}
+function findServiceAction(bar, serviceLogicalTypeName, actionId) {
+ for (const menu of bar?.menus ?? []) {
+ for (const section of menu.sections ?? []) {
+ const action = section.actions?.find(candidate => candidate.serviceLogicalTypeName === serviceLogicalTypeName
+ && candidate.actionId === actionId);
+ if (action) return action;
+ }
+ }
+ return null;
+}
+
function isLoading(status) {
return [
MenuBarsStatus.IDLE,
diff --git a/viewers/webcomponents/foundation/src/menubar-projection.mjs b/viewers/webcomponents/foundation/src/menubar-projection.mjs
index ac863ea954f..197d1b3a5a9 100644
--- a/viewers/webcomponents/foundation/src/menubar-projection.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-projection.mjs
@@ -51,6 +51,7 @@ export function projectCausewayMenuBar(bar, {generation = 0, excludeAction = nul
label: boundedText(action?.label || actionId),
description: boundedText(action?.description),
iconHint: boundedText(action?.iconHint, 128),
+ iconPosition: action?.iconPosition === 'RIGHT' ? 'RIGHT' : 'LEFT',
disabled: Boolean(action?.disabled),
disabledReason: boundedText(action?.disabled),
generation
@@ -111,11 +112,13 @@ export function createVaadinMenuItems(projection) {
children: Object.freeze(menu.sections.flatMap(section => {
const leaves = section.actions.map(action => Object.freeze({
text: action.label,
- title: action.description || action.disabledReason,
+ title: boundedTooltip(action.description, action.disabledReason),
+ description: action.description,
disabled: action.disabled,
causewayKind: 'action',
causewayKey: action.key,
causewayIconHint: action.iconHint,
+ causewayIconPosition: action.iconPosition,
causewayDisabledReason: action.disabledReason
}));
if (!section.label) return leaves;
@@ -151,6 +154,10 @@ function projectionError(code) {
return error;
}
+function boundedTooltip(description, disabledReason) {
+ return [boundedText(description), boundedText(disabledReason)].filter(Boolean).join('\n\n');
+}
+
function boundedIdentity(value) {
const text = String(value ?? '');
return /^[-._A-Za-z0-9]{1,256}$/.test(text) ? text : '';
diff --git a/viewers/webcomponents/foundation/src/menubar-widget.mjs b/viewers/webcomponents/foundation/src/menubar-widget.mjs
index a889def29ba..e35def72e52 100644
--- a/viewers/webcomponents/foundation/src/menubar-widget.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-widget.mjs
@@ -6,6 +6,10 @@
* to you under the Apache License, Version 2.0.
*/
+import {
+ appendActionContent,
+ normalizeActionPresentation
+} from './action-presentation.mjs';
import {createVaadinMenuItems, resolveCausewayMenuAction} from './menubar-projection.mjs';
export const CAUSEWAY_MENUBAR_CONTROL = 'cw-menubar-control';
@@ -235,9 +239,15 @@ function materializeMenuItems(items) {
const menuItem = document.createElement('vaadin-menu-bar-item');
if (item.causewayKey) menuItem.dataset.causewayKey = item.causewayKey;
const label = document.createElement('span');
- label.className = 'causeway-menubar-item-label';
- label.textContent = item.text;
- if (item.title) label.title = item.title;
+ const presentation = normalizeActionPresentation({
+ name: item.text,
+ description: item.description,
+ cssClassFa: item.causewayIconHint,
+ cssClassFaPosition: item.causewayIconPosition
+ });
+ label.className = `causeway-menubar-item-label${item.title ? ' causeway-action-control-tooltip' : ''}`;
+ appendActionContent(label, item.text, presentation.icon);
+ if (item.title) label.dataset.tooltip = item.title;
if (item.causewayIconHint) label.dataset.iconHint = item.causewayIconHint;
if (item.causewayDisabledReason) {
label.setAttribute('aria-label', `${item.text}. Unavailable: ${item.causewayDisabledReason}`);
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index d49151856c1..b32e273de3b 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -1327,8 +1327,11 @@ function translateRequirement(requirement, description) {
}
const supportedFields = ['hidden', 'disabled'].filter(field => member.fields.has(field));
const memberSelection = Object.fromEntries(supportedFields.map(field => [field, true]));
- const metadataFields = ['friendlyName', 'description']
- .filter(field => member.metadata?.fields.has(field));
+ const metadataFields = [
+ 'friendlyName',
+ 'description',
+ ...(requirement.kind === 'action' ? ['cssClassFa', 'cssClassFaPosition'] : [])
+ ].filter(field => member.metadata?.fields.has(field));
if (metadataFields.length > 0) {
memberSelection.metadata = Object.fromEntries(metadataFields.map(field => [field, true]));
}
diff --git a/viewers/webcomponents/foundation/test/action-presentation.test.mjs b/viewers/webcomponents/foundation/test/action-presentation.test.mjs
new file mode 100644
index 00000000000..9a1981fcc70
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/action-presentation.test.mjs
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ composeActionTooltip,
+ normalizeActionPresentation,
+ normalizeFontAwesomeIcon,
+ renderActionContent
+} from '../src/action-presentation.mjs';
+
+test('normalizes bounded distinct action presentation', () => {
+ assert.deepEqual(normalizeActionPresentation({
+ friendlyName: 'Place order',
+ description: 'Creates an order',
+ cssClassFa: 'cart-shopping',
+ cssClassFaPosition: 'RIGHT'
+ }), {
+ name: 'Place order',
+ description: 'Creates an order',
+ icon: {classes: ['fa-solid', 'fa-cart-shopping'], position: 'RIGHT'}
+ });
+ assert.equal(normalizeActionPresentation({name: 'Delete', description: 'delete'}).description, '');
+});
+
+test('rejects malformed Font Awesome hints and normalizes accepted classes and position', () => {
+ assert.deepEqual(normalizeFontAwesomeIcon('fa-regular fa-calendar-check', 'LEFT'), {
+ classes: ['fa-regular', 'fa-calendar-check'],
+ position: 'LEFT'
+ });
+ assert.equal(normalizeFontAwesomeIcon('fa-user\" onclick="alert(1)', 'RIGHT'), null);
+ assert.equal(normalizeFontAwesomeIcon('', 'LEFT'), null);
+});
+
+test('composes tooltip sections and renders escaped positioned icon content', () => {
+ assert.equal(composeActionTooltip('Creates an order', 'Credit is unavailable'),
+ 'Creates an order\n\nCredit is unavailable');
+ const right = normalizeFontAwesomeIcon('cart-shopping', 'RIGHT');
+ assert.equal(renderActionContent('Place <order>', right),
+ '<span class="causeway-action-label">Place <order></span><i class="causeway-action-icon fa-solid fa-cart-shopping" aria-hidden="true"></i>');
+});
diff --git a/viewers/webcomponents/foundation/test/action-widget.test.mjs b/viewers/webcomponents/foundation/test/action-widget.test.mjs
index 289c0e47c88..d7c446e5531 100644
--- a/viewers/webcomponents/foundation/test/action-widget.test.mjs
+++ b/viewers/webcomponents/foundation/test/action-widget.test.mjs
@@ -31,7 +31,7 @@ test('action widget defaults enabled and preserves a native loading fallback', (
assert.match(html, /<cw-action-control/);
assert.match(html, /data-label="Inspect"/);
assert.match(html, /data-control-testid="inspect-control"/);
- assert.match(html, /<button type="button" data-causeway-action-control aria-describedby="inspect-description" data-testid="inspect-control">Inspect<\/button>/);
+ assert.match(html, /<button type="button" data-causeway-action-control aria-describedby="inspect-description" data-testid="inspect-control"><span class="causeway-action-label">Inspect<\/span><\/button>/);
});
test('native action markup retains disabled semantics', () => {
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index 1cfe899b1fa..52dfc9e72e2 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -48,8 +48,8 @@ test('property editor actions remain compact deterministic icon controls', async
test('member tooltips preserve sections, responsive bounds and pointer and focus access', async () => {
assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-property-disabled-tooltip \{[\s\S]*?cursor: help;[\s\S]*?position: relative;/);
- assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-member-tooltip \{[\s\S]*?cursor: help;[\s\S]*?position: relative;/);
- assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-member-tooltip::after \{[\s\S]*?content: attr\(data-tooltip\);[\s\S]*?max-inline-size: min\(24rem, calc\(100vw - 2rem\)\);[\s\S]*?white-space: pre-line;/);
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-member-tooltip,\s+\.causeway-action-control-tooltip \{[\s\S]*?cursor: help;[\s\S]*?position: relative;/);
+ assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-member-tooltip::after,\s+\.causeway-action-control-tooltip::after \{[\s\S]*?content: attr\(data-tooltip\);[\s\S]*?max-inline-size: min\(24rem, calc\(100vw - 2rem\)\);[\s\S]*?white-space: pre-line;/);
assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-member-tooltip:hover::after,[\s\S]*?\.causeway-member-tooltip:focus-visible::after/);
assert.doesNotMatch(CAUSEWAY_COMPONENT_STYLES, /causeway-property-disabled-indicator/);
assert.match(CAUSEWAY_COMPONENT_STYLES, /\.causeway-property-value-string \{\s+text-align: start;/);
diff --git a/viewers/webcomponents/foundation/test/components.test.mjs b/viewers/webcomponents/foundation/test/components.test.mjs
index 802ffbfa713..be121e9ed26 100644
--- a/viewers/webcomponents/foundation/test/components.test.mjs
+++ b/viewers/webcomponents/foundation/test/components.test.mjs
@@ -30,6 +30,7 @@ const {
CausewayCollectionElement,
CausewayObjectContextElement,
CausewayPropertyElement,
+ CausewaySemanticEvent,
CausewayValueRendererRegistry,
configureCausewayFieldWidgets,
defineCausewayWebComponents
@@ -344,6 +345,48 @@ test('property presentation selects every qualified field family and preserves d
}
});
+test('action names descriptions disabled reasons and Font Awesome hints share one presentation', () => {
+ const action = new CausewayActionElement();
+ action.id = 'placeOrder';
+ action.label = 'Legacy order label';
+ action.named = 'Place a new order';
+ action.context = {identity: {logicalTypeName: 'example.Order', id: '42'}};
+ action.acceptComponentState(state({
+ descriptor: {id: 'placeOrder', description: 'Schema fallback'},
+ data: {
+ hidden: false,
+ disabled: 'Ordering is unavailable.',
+ metadata: {
+ friendlyName: 'Canonical order name',
+ description: 'Creates an order for this customer.',
+ cssClassFa: 'cart-shopping',
+ cssClassFaPosition: 'RIGHT'
+ }
+ }
+ }));
+
+ assert.match(action.innerHTML, /Place a new order/);
+ assert.doesNotMatch(action.innerHTML, /Legacy order label|Canonical order name/);
+ assert.match(action.innerHTML, /causeway-action-label">Place a new order<\/span><i class="causeway-action-icon fa-solid fa-cart-shopping"/);
+ assert.match(action.innerHTML, /data-tooltip="Creates an order for this customer\.\n\nOrdering is unavailable\."/);
+ assert.match(action.innerHTML, /aria-describedby="causeway-action-description-\d+ causeway-action-reason-\d+"/);
+ assert.equal(action.activate(), false);
+
+ let request;
+ action.addEventListener(CausewaySemanticEvent.ACTION_REQUEST, event => { request = event.detail; });
+ action.acceptComponentState(state({
+ descriptor: {id: 'placeOrder'},
+ data: {
+ hidden: false,
+ disabled: null,
+ metadata: {friendlyName: 'Canonical order name', description: 'Creates an order for this customer.'}
+ }
+ }));
+ assert.equal(action.activate(), true);
+ assert.equal(request.presentation.name, 'Place a new order');
+ assert.equal(request.presentation.description, 'Creates an order for this customer.');
+});
+
test('member-bearing elements use native identifiers without a member compatibility API', () => {
const property = new CausewayPropertyElement();
const action = new CausewayActionElement();
diff --git a/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs b/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
index a95384c978a..2989253ecd9 100644
--- a/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
@@ -28,12 +28,13 @@ export const MENU_BARS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<mb:named>Samples & Objects</mb:named>
<mb:section>
<mb:named>Explore</mb:named>
- <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="welcomeMessage" cssClassFa="fa-message">
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="welcomeMessage" cssClassFa="fa-message" cssClassFaPosition="RIGHT">
<cpt:named>Welcome Message</cpt:named>
<cpt:describedAs>Return a friendly greeting.</cpt:describedAs>
</mb:serviceAction>
<mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="disabledAction">
<cpt:named>Disabled Action</cpt:named>
+ <cpt:describedAs>Performs an administrative operation.</cpt:describedAs>
</mb:serviceAction>
<mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="hiddenAction"/>
</mb:section>
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index 440c549f744..04c68f53e7f 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -87,6 +87,8 @@ export function createRichSchemaTypes() {
[MEMBER_METADATA_TYPE, objectType(MEMBER_METADATA_TYPE, 'Member metadata', [
field('friendlyName', null, scalar('String')),
field('description', null, scalar('String')),
+ field('cssClassFa', null, scalar('String')),
+ field('cssClassFaPosition', null, scalar('String')),
field('multiLine', null, scalar('Int')),
field('labelPosition', null, scalar('String'))
])],
@@ -128,6 +130,7 @@ export function createRichSchemaTypes() {
[ACTION_TYPE, objectType(ACTION_TYPE, null, [
field('hidden', null, scalar('Boolean')),
field('disabled', null, scalar('String')),
+ field('metadata', null, named(MEMBER_METADATA_TYPE)),
field('params', null, named(ACTION_PARAMS_TYPE))
])],
[ACTION_PARAMS_TYPE, objectType(ACTION_PARAMS_TYPE, null, [
@@ -356,7 +359,16 @@ export function departmentObjectData({
disabled: null,
metadata: {friendlyName: 'Former staff', description: null}
},
- changeName: {hidden: false, disabled: null}
+ changeName: {
+ hidden: false,
+ disabled: null,
+ metadata: {
+ friendlyName: 'Rename department',
+ description: 'Changes the department display name.',
+ cssClassFa: 'pen-to-square',
+ cssClassFaPosition: 'RIGHT'
+ }
+ }
};
}
diff --git a/viewers/webcomponents/foundation/test/interaction.test.mjs b/viewers/webcomponents/foundation/test/interaction.test.mjs
index 0a9bbc6b555..cb57617bb47 100644
--- a/viewers/webcomponents/foundation/test/interaction.test.mjs
+++ b/viewers/webcomponents/foundation/test/interaction.test.mjs
@@ -747,8 +747,14 @@ test('standard action controller renders prompts, blocks invalid input, invokes
document.body.appendChild(controller);
let semanticResult;
controller.addEventListener('causeway-action-result', event => { semanticResult = event.detail; });
- assert.equal(await controller.beginAction('changeName', context), true);
+ assert.equal(await controller.beginAction('changeName', context, null, {
+ name: 'Rename this object',
+ description: 'Changes the display name.'
+ }), true);
assert.match(controller.innerHTML, /data-testid="action-prompt"/);
+ assert.match(controller.innerHTML, /<h2[^>]*>Rename this object<\/h2>/);
+ assert.match(controller.innerHTML, /causeway-action-prompt-description">Changes the display name\.<\/p>/);
+ assert.match(controller.innerHTML, /aria-describedby="causeway-action-prompt-description-\d+"/);
assert.match(controller.innerHTML, /action-prompt-parameter-newName/);
assert.equal((controller.innerHTML.match(/>New name</gi) ?? []).length, 1);
await controller.setParameterValue('newName', 'Invalid', {recompute: false});
diff --git a/viewers/webcomponents/foundation/test/menubar-component.test.mjs b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
index 2c331c75087..682f2229db3 100644
--- a/viewers/webcomponents/foundation/test/menubar-component.test.mjs
+++ b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
@@ -55,8 +55,9 @@ test('semantic bar renders labelled navigation, disclosures, text-safe hints, di
assert.match(markup, /data-causeway-menu-disclosure/);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /data-icon-hint="fa-building"/);
- assert.match(markup, /Welcome Message/);
+ assert.match(markup, /<span class="causeway-action-label">Welcome Message<\/span><i class="causeway-action-icon fa-solid fa-message"/);
assert.match(markup, /disabled aria-disabled="true"/);
+ assert.match(markup, /data-tooltip="Performs an administrative operation\.\n\nAvailable to administrators only\."/);
assert.match(markup, /Available to administrators only/);
assert.doesNotMatch(markup, /hiddenAction/);
assert.doesNotMatch(markup, /role="menubar"|role="menuitem"/);
@@ -119,6 +120,9 @@ test('menu action selection, outside activation, Escape, and sibling opening clo
actionClick.target = action;
bar.dispatchEvent(actionClick);
assert.equal(request.actionId, 'welcomeMessage');
+ assert.equal(request.presentation.name, 'Welcome Message');
+ assert.equal(request.presentation.description, 'Return a friendly greeting.');
+ assert.deepEqual(request.presentation.icon, {classes: ['fa-solid', 'fa-message'], position: 'RIGHT'});
assert.equal(requestCount, 1);
assert.equal(Object.hasOwn(request, 'focusTarget'), false);
assert.equal(firstDisclosure.getAttribute('aria-expanded'), 'false');
diff --git a/viewers/webcomponents/foundation/test/object-context.test.mjs b/viewers/webcomponents/foundation/test/object-context.test.mjs
index 67569c9397f..f1d1ce2382b 100644
--- a/viewers/webcomponents/foundation/test/object-context.test.mjs
+++ b/viewers/webcomponents/foundation/test/object-context.test.mjs
@@ -133,6 +133,43 @@ test('property reads remain compatible when member metadata is unavailable', asy
assert.equal(propertyState.data.get, 'Classics');
});
+test('action reads select available canonical presentation metadata', async () => {
+ const executor = createRichSchemaFixtureExecutor();
+ const context = createContext(executor);
+ let actionState;
+ context.registerRequirement({kind: 'action', member: 'changeName'}, state => { actionState = state; });
+ await waitFor(() => context.state.status === 'ready');
+
+ const actionDocument = executor.readCalls[0].document;
+ assert.match(actionDocument, /changeName\s*\{[\s\S]*metadata\s*\{/);
+ for (const field of ['friendlyName', 'description', 'cssClassFa', 'cssClassFaPosition']) {
+ assert.match(actionDocument, new RegExp(`\\b${field}\\b`));
+ }
+ assert.deepEqual(actionState.data.metadata, {
+ friendlyName: 'Rename department',
+ description: 'Changes the department display name.',
+ cssClassFa: 'pen-to-square',
+ cssClassFaPosition: 'RIGHT'
+ });
+});
+
+test('action reads remain compatible when presentation metadata is unavailable', async () => {
+ const types = createRichSchemaTypes();
+ for (const [name, type] of types) {
+ if (name.endsWith('__gqlv_action')) {
+ types.set(name, {...type, fields: type.fields.filter(field => field.name !== 'metadata')});
+ }
+ }
+ const executor = createRichSchemaFixtureExecutor({types});
+ const context = createContext(executor);
+ let actionState;
+ context.registerRequirement({kind: 'action', member: 'changeName'}, state => { actionState = state; });
+ await waitFor(() => context.state.status === 'ready');
+
+ assert.doesNotMatch(executor.readCalls[0].document, /metadata\s*\{/);
+ assert.equal(actionState.status, 'ready');
+});
+
test('collection reads include only supported canonical heading metadata', async () => {
const executor = createRichSchemaFixtureExecutor();
const context = createContext(executor);
diff --git a/viewers/webcomponents/htmx/README.adoc b/viewers/webcomponents/htmx/README.adoc
index 2f30e43d7c8..59ce177f2dd 100644
--- a/viewers/webcomponents/htmx/README.adoc
+++ b/viewers/webcomponents/htmx/README.adoc
@@ -49,6 +49,8 @@ causeway.viewer.webcomponents.htmx.component-toolkit=native
----
The complete shell applies a restrictive same-origin content-security policy and returns `private, no-store`.
+It loads the pinned Font Awesome WebJar stylesheet and fonts from the application origin so static `@ActionLayout(cssClassFa, cssClassFaPosition)` hints render without CDN access or inline styles.
+Malformed, absent, or dynamic action icon metadata remains icon-free without weakening the action or shell.
The default Vaadin policy adds the deterministic deduplicated union of only the reviewed byte-exact `style-src` and `style-src-elem` SHA-256 sources, retains `style-src-attr 'none'`, and never adds `unsafe-inline`.
The reference, basic, numeric, local-temporal, action-button, Grid, and Menu Bar JavaScript closures remain independent and are requested only when an eligible connected editor, read-only field, ordinary action, active wide qualified collection, or non-empty qualified stable-shell menu tier selects them.
Menu Bar is shared by all tiers and later routes after its first qualified use, while login, challenge, authentication failure, logout, empty-menu, unsupported-menu, and explicit-native chrome request zero Menu Bar bytes.
diff --git a/viewers/webcomponents/htmx/pom.xml b/viewers/webcomponents/htmx/pom.xml
index ff62d53265b..1d8f07f2cca 100644
--- a/viewers/webcomponents/htmx/pom.xml
+++ b/viewers/webcomponents/htmx/pom.xml
@@ -62,6 +62,11 @@
<artifactId>htmx.org</artifactId>
<version>${htmx.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.webjars</groupId>
+ <artifactId>font-awesome</artifactId>
+ <version>7.3.0</version>
+ </dependency>
<dependency>
<groupId>org.springframework</groupId>
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
index aaf058f0202..b3f3ff149bf 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
@@ -24,6 +24,7 @@
final class HtmxPageRenderer {
private static final String HTMX_VERSION = "2.0.6";
+ private static final String FONT_AWESOME_VERSION = "7.3.0";
private final HtmxRouteCodec routeCodec;
private final HtmxViewerProperties properties;
@@ -116,6 +117,7 @@ String renderShell(
<meta name="htmx-config" content='{"historyCacheSize":0,"historyRestoreAsHxRequest":false,"includeIndicatorStyles":false}'>
%s
<title>%s</title>
+ <link rel="stylesheet" href="%s/webjars/font-awesome/%s/css/all.min.css">
<link rel="stylesheet" href="%s/causeway-webcomponents/component-styles.css">
<link rel="stylesheet" href="%s/causeway-webcomponents/theme.css">
<link rel="stylesheet" href="%s/causeway-htmx/causeway-htmx.css">
@@ -157,6 +159,8 @@ String renderShell(
authenticationMetadata,
escape(properties.getBrand()),
escape(context),
+ FONT_AWESOME_VERSION,
+ escape(context),
escape(context),
escape(context),
applicationStylesheet,
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
index 9b26697bb0a..ccfcf607c9a 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
@@ -48,6 +48,8 @@ void returnsCompleteStableShellForOrdinaryObjectRequest() {
.contains("data-page-kind=\"generic\"")
.contains("data-page-source=\"generic\"")
.contains("<cw-object-context logical-type=\"petclinic.PetOwner\" object-id=\"owner-1\">")
+ .contains("<link rel=\"stylesheet\" href=\"/app/webjars/font-awesome/7.3.0/css/all.min.css\">")
+ .doesNotContain("cdnjs.cloudflare.com", "use.fontawesome.com")
.contains("<cw-object editable>")
.contains("<cw-interaction-controller data-causeway-route-interactions>");
assertThat(response.getHeaders().getFirst("Content-Security-Policy"))
@@ -117,7 +119,8 @@ void explicitNativePolicyRemovesVaadinAdaptersAndHashes() {
.contains("data-causeway-application-menubar=\"native\"")
.contains("data-causeway-menubar-family=\"native\"")
.contains("data-causeway-reference-widgets=\"native\"")
- .contains("data-causeway-field-families=\"\"");
+ .contains("data-causeway-field-families=\"\"")
+ .contains("<link rel=\"stylesheet\" href=\"/webjars/font-awesome/7.3.0/css/all.min.css\">");
}
@Test
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
index 838f6ad9314..9bff4fd3626 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -12,6 +12,8 @@ The Wicket viewer is a side-by-side presentation reference rather than a depende
The sample uses the supported default route-lazy Vaadin free-core reference adapter and qualified basic, numeric, and local-temporal field families without an application opt-in.
Its strict CSP adds only the pinned deduplicated hashes for reviewed closures, preserves `style-src-attr 'none'`, and makes no Vaadin request until an eligible editor opens.
Causeway elements, codecs, GraphQL interactions, and semantic events remain authoritative, and the common native toolkit policy provides immediate rollback.
+The owner page demonstrates page-local action names such as `Change the owner's name`, `Register a pet`, and `Remove this owner` without changing semantic action IDs.
+Owner and service actions demonstrate canonical descriptions, parameter-prompt descriptions, disabled-reason tooltip composition, and static Font Awesome icons in native and Vaadin presentations.
== Run
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
index 3329f504227..7220c25761b 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
@@ -53,6 +53,7 @@
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.PropertyLayout;
import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.layout.component.CssClassFaPosition;
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.message.MessageService;
import org.apache.causeway.applib.services.repository.RepositoryService;
@@ -214,7 +215,11 @@ public Long getDaysSinceLastVisit() {
}
@Action
- @ActionLayout(associateWith = "pets", sequence = "1")
+ @ActionLayout(
+ associateWith = "pets",
+ sequence = "1",
+ describedAs = "Adds a pet to this owner's household.",
+ cssClassFa = "paw")
public PetOwner addPet(
@Parameter(maxLength = 40) final String name,
final PetSpecies species) {
@@ -231,7 +236,11 @@ public String validate0AddPet(final String candidateName) {
}
@Action(choicesFrom = "pets")
- @ActionLayout(associateWith = "pets", sequence = "2")
+ @ActionLayout(
+ associateWith = "pets",
+ sequence = "2",
+ describedAs = "Removes a pet and its visits from this owner.",
+ cssClassFa = "circle-minus")
public PetOwner removePet(final Pet pet) {
pet.clearVisits();
pets.remove(pet);
@@ -244,7 +253,10 @@ public Pet default0RemovePet() {
}
@Action(semantics = SemanticsOf.IDEMPOTENT)
- @ActionLayout(describedAs = "Updates the owner's full name.")
+ @ActionLayout(
+ describedAs = "Updates the owner's full name.",
+ cssClassFa = "pen-to-square",
+ cssClassFaPosition = CssClassFaPosition.RIGHT)
public PetOwner updateName(@Parameter(maxLength = 80) final String name) {
setName(name);
return this;
@@ -264,13 +276,22 @@ public String validate0UpdateName(final String candidateName) {
}
@Action(semantics = SemanticsOf.NON_IDEMPOTENT_ARE_YOU_SURE)
- @ActionLayout(describedAs = "Deletes this pet owner and their related pets and visits.")
+ @ActionLayout(
+ describedAs = "Deletes this pet owner and their related pets and visits.",
+ cssClassFa = "trash-can")
public void delete() {
final var title = titleService.titleOf(this);
repositoryService.removeAndFlush(this);
messageService.informUser("'" + title + "' deleted");
}
+ @MemberSupport
+ public String disableDelete() {
+ return "owner-mary".equals(id)
+ ? "The fixture owner is retained for the presentation demonstration."
+ : null;
+ }
+
void addSeedPet(final Pet pet) {
pets.add(pet);
}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
index d69c66bd31e..e2a9e940bca 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
@@ -45,7 +45,7 @@ public PetOwners(final PetOwnerRepository repository) {
}
@Action(semantics = SemanticsOf.NON_IDEMPOTENT)
- @ActionLayout(describedAs = "Registers a new pet owner.")
+ @ActionLayout(describedAs = "Registers a new pet owner.", cssClassFa = "user-plus")
public PetOwner create(
@Parameter(maxLength = 80) final String name,
@Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) final String knownAs,
@@ -59,22 +59,25 @@ public PetOwner create(
}
@Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(describedAs = "Finds owners whose names contain the search text.", cssClassFa = "magnifying-glass")
public List<PetOwner> findByName(@Parameter(maxLength = 80) final String name) {
return repository.findByNameContaining(name);
}
@Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(describedAs = "Finds owners using the demonstration name search.", cssClassFa = "magnifying-glass")
public List<PetOwner> findByNameLike(@Parameter(maxLength = 80) final String name) {
return repository.findByNameContaining(name);
}
@Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(describedAs = "Lists every registered pet owner.", cssClassFa = "users")
public List<PetOwner> listAll() {
return repository.findAll();
}
@Action(semantics = SemanticsOf.SAFE)
- @ActionLayout(describedAs = "Counts the registered pet owners.")
+ @ActionLayout(describedAs = "Counts the registered pet owners.", cssClassFa = "calculator")
public long count() {
return repository.count();
}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
index a86d98277c5..a0168e95f64 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
@@ -18,7 +18,7 @@
<cw-object-header></cw-object-header>
<div class="petclinic-page-toolbar" aria-label="Owner actions">
- <cw-action id="delete"></cw-action>
+ <cw-action id="delete" named="Remove this owner"></cw-action>
</div>
<div class="petclinic-object-grid">
@@ -27,7 +27,7 @@
<h2 id="petclinic-owner-identity-heading">Identity</h2>
<div class="petclinic-field-list">
<cw-property id="name" named="Full name">
- <cw-action id="updateName"></cw-action>
+ <cw-action id="updateName" named="Change the owner's name"></cw-action>
</cw-property>
<cw-property id="knownAs" editable
described-as="The familiar or preferred name used by this owner."></cw-property>
@@ -59,7 +59,7 @@ <h2 id="petclinic-owner-pets-heading">Pets</h2>
<cw-collection-column id="name" label="Name"></cw-collection-column>
<cw-collection-column id="species" label="Species"></cw-collection-column>
<cw-collection-column id="notes" label="Notes"></cw-collection-column>
- <cw-action id="addPet"></cw-action>
+ <cw-action id="addPet" named="Register a pet"></cw-action>
<cw-action id="removePet"></cw-action>
</cw-collection>
</section>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml
index a63ea9d8bcd..2cd28190d2a 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml
@@ -15,10 +15,16 @@
<mb:named>Pet Owners</mb:named>
<mb:section>
<mb:named>Manage</mb:named>
- <mb:serviceAction objectType="petclinic.PetOwners" id="create"/>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="create" cssClassFa="fa-user-plus">
+ <cpt:named>Create</cpt:named>
+ <cpt:describedAs>Registers a new pet owner.</cpt:describedAs>
+ </mb:serviceAction>
<mb:serviceAction objectType="petclinic.PetOwners" id="findByName"/>
<mb:serviceAction objectType="petclinic.PetOwners" id="findByNameLike"/>
- <mb:serviceAction objectType="petclinic.PetOwners" id="listAll"/>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="listAll" cssClassFa="fa-users" cssClassFaPosition="LEFT">
+ <cpt:named>List All</cpt:named>
+ <cpt:describedAs>Lists every registered pet owner.</cpt:describedAs>
+ </mb:serviceAction>
<mb:serviceAction objectType="petclinic.PetOwners" id="count"/>
</mb:section>
</mb:menu>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
index 8460e6b2fe3..8a36fab0c18 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
@@ -22,6 +22,7 @@
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import com.microsoft.playwright.Browser;
@@ -300,6 +301,21 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
.isEqualTo(1);
assertThat(page.locator("cw-collection[id='visits'] > cw-action[id='bookVisit']").count())
.isEqualTo(1);
+ final var deleteAction = page.locator("cw-action[id='delete']");
+ assertThat(deleteAction.locator(".causeway-action-label").first().textContent())
+ .isEqualTo("Remove this owner");
+ assertThat(deleteAction.locator(".causeway-action-icon.fa-trash-can").count()).isGreaterThan(0);
+ assertThat(deleteAction.locator(".causeway-action-control-tooltip").getAttribute("data-tooltip"))
+ .isEqualTo("Deletes this pet owner and their related pets and visits.\n\n"
+ + "The fixture owner is retained for the presentation demonstration.");
+ assertThat(objectAction("updateName").textContent()).contains("Change the owner's name");
+ assertThat(page.locator("cw-action[id='updateName'] .causeway-action-label + .causeway-action-icon.fa-pen-to-square").count())
+ .isGreaterThan(0);
+ assertThat(objectAction("addPet").textContent()).contains("Register a pet");
+ assertThat(page.locator("cw-action[id='addPet'] .causeway-action-icon.fa-paw + .causeway-action-label").count())
+ .isGreaterThan(0);
+ assertThat(objectAction("removePet").textContent()).contains("Remove Pet");
+ assertThat(objectAction("bookVisit").textContent()).contains("Book Visit");
assertDefaultOrNativeMemberPresentation("name", "delete");
assertSingleToolkitFieldBoundary("name");
assertSingleToolkitFieldBoundary("telephoneNumber");
@@ -413,6 +429,22 @@ void serviceActionsCoverValidationCancellationScalarAndCollections() {
}
assertThat(petclinicServiceActionIds()).containsExactlyInAnyOrder(
"create", "findByName", "findByNameLike", "listAll", "count", "listUpcoming");
+ if (nativeToolkit()) {
+ final var listAllPresentation = serviceActionPresentation("listAll");
+ listAllPresentation.waitFor();
+ assertThat(listAllPresentation.locator(".causeway-action-icon.fa-users").count()).isGreaterThan(0);
+ assertThat(listAllPresentation.getAttribute("data-tooltip"))
+ .isEqualTo("Lists every registered pet owner.");
+ } else {
+ final var listAllDescriptor = (Map<?, ?>) page.evaluate("""
+ () => Object.values(document.querySelector('cw-menubar-primary')._projection.actions)
+ .find(action => action.actionId === 'listAll')
+ """);
+ assertThat(String.valueOf(listAllDescriptor.get("iconHint"))).endsWith("users");
+ assertThat(listAllDescriptor.get("iconPosition")).isEqualTo("LEFT");
+ assertThat(listAllDescriptor.get("description"))
+ .isEqualTo("Lists every registered pet owner.");
+ }
activateServiceAction("listAll");
waitForShellResult("listAll", "4 results");
@@ -453,6 +485,8 @@ void serviceActionsCoverValidationCancellationScalarAndCollections() {
openMenu("Pet Owners");
activateServiceAction("create");
waitForPrompt("create");
+ assertThat(page.locator(PROMPT + " .causeway-action-prompt-description").textContent())
+ .isEqualTo("Registers a new pet owner.");
cancelPrompt();
assertMenuClosedAndFocused("Pet Owners");
@@ -555,7 +589,9 @@ void propertyEditingAndPromptFocusRemainDeterministic() {
final var updateName = objectAction("updateName");
updateName.click();
- waitForPrompt("updateName");
+ waitForPrompt("updateName", "Change the owner's name");
+ assertThat(page.locator(PROMPT + " .causeway-action-prompt-description").textContent())
+ .isEqualTo("Updates the owner's full name.");
assertFocused(parameter("name"));
fillParameter("name", "Invalid % name");
submitPrompt();
@@ -565,7 +601,7 @@ void propertyEditingAndPromptFocusRemainDeterministic() {
assertFocused("cw-action[id='updateName'] [data-causeway-action-control]");
updateName.click();
- waitForPrompt("updateName");
+ waitForPrompt("updateName", "Change the owner's name");
resolveEditor(parameter("name")).press("Escape");
page.locator(PROMPT).waitFor(new Locator.WaitForOptions().setState(com.microsoft.playwright.options.WaitForSelectorState.DETACHED));
assertFocused("cw-action[id='updateName'] [data-causeway-action-control]");
@@ -593,7 +629,7 @@ void disposableOwnerExercisesCreateAndEveryObjectAction() {
final var updateNameMutations = graphQLMutationCount("updateName");
objectAction("updateName").click();
- waitForPrompt("updateName");
+ waitForPrompt("updateName", "Change the owner's name");
fillParameter("name", "Playwright Owner Updated");
submitPromptExpectingNavigation();
waitForObjectTitle("Playwright Owner Updated");
@@ -603,7 +639,9 @@ void disposableOwnerExercisesCreateAndEveryObjectAction() {
final var addPetMutations = graphQLMutationCount("addPet");
objectAction("addPet").click();
- waitForPrompt("addPet");
+ waitForPrompt("addPet", "Register a pet");
+ assertThat(page.locator(PROMPT + " .causeway-action-prompt-description").textContent())
+ .isEqualTo("Adds a pet to this owner's household.");
assertThat(page.locator(PROMPT + " .causeway-action-parameter-reason").count()).isZero();
final var preparationsBeforeName = graphQLOperationCount("CausewayPrepareAction");
tabOutOfParameter("name");
@@ -820,6 +858,10 @@ private Locator serviceAction(final String actionId) {
return page.locator("[data-service-logical-type^='petclinic.'][data-action-id='" + actionId + "']").first();
}
+ private Locator serviceActionPresentation(final String actionId) {
+ return serviceAction(actionId).locator("xpath=..");
+ }
+
private Set<String> petclinicServiceActionIds() {
if (!nativeToolkit()) {
final var values = (List<?>) page.evaluate("() => [...document.querySelectorAll('cw-menubar-primary, cw-menubar-secondary, cw-menubar-tertiary')].flatMap(host => Object.values(host._projection?.actions ?? {}).filter(action => action.serviceLogicalTypeName.startsWith('petclinic.')).map(action => action.actionId))");
@@ -886,8 +928,12 @@ private Locator objectAction(final String member) {
}
private void waitForPrompt(final String actionId) {
+ waitForPrompt(actionId, humanize(actionId));
+ }
+
+ private void waitForPrompt(final String actionId, final String expectedTitle) {
page.locator(PROMPT).waitFor();
- assertThat(page.locator(PROMPT + " h2").textContent()).isEqualTo(humanize(actionId));
+ assertThat(page.locator(PROMPT + " h2").textContent()).isEqualTo(expectedTitle);
}
private void fillParameter(final String parameterId, final String value) {
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
index e6e51257a4d..a4cfb9cbaad 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
@@ -97,6 +97,7 @@ void servesStableShellCanonicalFragmentsAssetsAndWicketComparison() throws Excep
.contains("data-navigation-generation=\"0\"")
.contains("/causeway-htmx/causeway-htmx.mjs")
.contains("/webjars/htmx.org/2.0.6/dist/htmx.min.js")
+ .contains("/webjars/font-awesome/7.3.0/css/all.min.css")
.contains("data-causeway-component-toolkit=\"" + (nativeToolkit ? "native" : "vaadin") + "\"")
.contains("data-causeway-presentation=\"" + (nativeToolkit ? "native" : "vaadin") + "\"")
.contains("data-causeway-action-buttons=\"" + (nativeToolkit ? "native" : "vaadin") + "\"")
@@ -109,6 +110,12 @@ void servesStableShellCanonicalFragmentsAssetsAndWicketComparison() throws Excep
"petclinic.PetOwner",
"s_owner-mary",
"petclinic-owner-page");
+ assertThat(get("/htmx/object/petclinic.PetOwner/s_owner-mary").body())
+ .contains("<cw-action id=\"delete\" named=\"Remove this owner\"")
+ .contains("<cw-action id=\"updateName\" named=\"Change the owner's name\"")
+ .contains("<cw-action id=\"addPet\" named=\"Register a pet\"")
+ .contains("<cw-action id=\"removePet\"></cw-action>")
+ .contains("<cw-action id=\"bookVisit\"></cw-action>");
assertResourcePage(
"/htmx/object/petclinic.Pet/s_pet-basil",
"petclinic.Pet",
@@ -140,6 +147,8 @@ void servesStableShellCanonicalFragmentsAssetsAndWicketComparison() throws Excep
assertThat(get("/causeway-webcomponents/component-styles.css").statusCode()).isEqualTo(200);
assertThat(get("/causeway-webcomponents/theme.css").statusCode()).isEqualTo(200);
assertThat(get("/webjars/htmx.org/2.0.6/dist/htmx.min.js").statusCode()).isEqualTo(200);
+ assertThat(get("/webjars/font-awesome/7.3.0/css/all.min.css").statusCode()).isEqualTo(200);
+ assertThat(get("/webjars/font-awesome/7.3.0/webfonts/fa-solid-900.woff2").statusCode()).isEqualTo(200);
final var wicket = getWithoutRedirect("/wicket/");
assertThat(wicket.statusCode()).isIn(200, 302);
@@ -350,9 +359,13 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
query PetClinicService {
rich {
petclinic_PetOwners {
- listAll { hidden disabled invoke { results { _meta { id logicalTypeName title } } } }
+ listAll {
+ hidden disabled
+ metadata { friendlyName description cssClassFa cssClassFaPosition }
+ invoke { results { _meta { id logicalTypeName title } } }
+ }
findByName { hidden disabled }
- create { hidden disabled }
+ create { hidden disabled metadata { description cssClassFa cssClassFaPosition } }
}
petclinic_Visits {
listUpcoming { hidden disabled invoke { results { _meta { id logicalTypeName title } } } }
@@ -362,6 +375,14 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
""");
assertNoGraphQLErrors(service);
assertThat(service.at("/data/rich/petclinic_PetOwners/listAll/invoke/results").size()).isEqualTo(4);
+ assertThat(service.at("/data/rich/petclinic_PetOwners/listAll/metadata/description").asText())
+ .isEqualTo("Lists every registered pet owner.");
+ assertThat(service.at("/data/rich/petclinic_PetOwners/listAll/metadata/cssClassFa").asText())
+ .isEqualTo("users");
+ assertThat(service.at("/data/rich/petclinic_PetOwners/listAll/metadata/cssClassFaPosition").asText())
+ .isEqualTo("LEFT");
+ assertThat(service.at("/data/rich/petclinic_PetOwners/create/metadata/cssClassFa").asText())
+ .isEqualTo("user-plus");
assertThat(service.at("/data/rich/petclinic_Visits/listUpcoming/invoke/results").size()).isEqualTo(3);
final var owner = graphQL("""
@@ -379,7 +400,13 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
}
}
visits { metadata { friendlyName description } get { _meta { id logicalTypeName title } } }
- addPet { hidden disabled params { name { validity datatype } } }
+ addPet {
+ hidden disabled
+ metadata { friendlyName description cssClassFa cssClassFaPosition }
+ params { name { validity datatype } }
+ }
+ updateName { metadata { description cssClassFa cssClassFaPosition } }
+ delete { hidden disabled metadata { description cssClassFa cssClassFaPosition } }
bookVisit { hidden disabled }
}
}
@@ -400,6 +427,16 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/window/rows/0/name/get").asText())
.isEqualTo("Samantha");
assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/get").size()).isEqualTo(2);
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/addPet/metadata/description").asText())
+ .isEqualTo("Adds a pet to this owner's household.");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/addPet/metadata/cssClassFa").asText())
+ .isEqualTo("paw");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/addPet/metadata/cssClassFaPosition").asText())
+ .isEqualTo("LEFT");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/updateName/metadata/cssClassFaPosition").asText())
+ .isEqualTo("RIGHT");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/delete/disabled").asText())
+ .isEqualTo("The fixture owner is retained for the presentation demonstration.");
}
@Test