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/29 10:01:01 UTC
(causeway) branch htmx updated (e31bd024400 -> 3b422efe7ce)
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a change to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
from e31bd024400 docs(openspec): archive HTMX resource page reload
new 286f5d0d6f3 docs(openspec): propose breadcrumbs web component
new a1b92533d51 feat(webcomponents): add navigable breadcrumbs
new 771eeb3431e fix(webcomponents): preserve route focus after menu actions
new 74ec13e3990 docs(openspec): archive breadcrumbs web component
new 5ac4368599d docs(openspec): propose single field boundaries
new 723f85f814b fix(webcomponents): remove duplicate field borders
new 3b422efe7ce docs(openspec): archive single field boundaries
The 7 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../.openspec.yaml | 0
.../design.md | 99 +++++++++++++++++
.../proposal.md | 31 ++++++
.../specs/domain-web-components/spec.md | 57 ++++++++++
.../generic-htmx-web-component-viewer/spec.md | 35 ++++++
.../specs/graphql-web-component-context/spec.md | 40 +++++++
.../rich-graphql-navigable-breadcrumbs/spec.md | 64 +++++++++++
.../tasks.md | 36 +++++++
.../.openspec.yaml | 0
.../design.md | 51 +++++++++
.../proposal.md | 25 +++++
.../vaadin-semantic-presentation-adapters/spec.md | 24 +++++
.../tasks.md | 15 +++
openspec/specs/domain-web-components/spec.md | 56 ++++++++++
.../generic-htmx-web-component-viewer/spec.md | 34 ++++++
.../specs/graphql-web-component-context/spec.md | 39 +++++++
.../rich-graphql-navigable-breadcrumbs/spec.md | 70 ++++++++++++
.../vaadin-semantic-presentation-adapters/spec.md | 23 ++++
.../model/domain/common/query/CommonMeta.java | 5 +
.../common/query/meta/CommonMetaBreadcrumbs.java | 74 +++++++++++++
.../common/query/meta/CommonMetaFetcher.java | 76 +++++++++++++
.../query/meta/CommonMetaBreadcrumbsTest.java} | 56 ++++------
.../common/query/meta/CommonMetaFetcherTest.java | 120 +++++++++++++++++++++
.../viewer/test/domain/dept/StaffMember.java | 25 ++++-
...adata_is_additive_facet_driven_and_shared._.gql | 29 +++++
.../test/e2e/special/MemberMetadata_IntegTest.java | 31 ++++++
viewers/graphql/test/src/test/resources/schema.gql | 117 ++++++++++++++++++++
viewers/webcomponents/foundation/USAGE.adoc | 15 ++-
.../foundation/src/breadcrumbs-element.mjs | 62 +++++++++++
.../foundation/src/component-contracts.mjs | 2 +
.../foundation/src/component-styles.css | 33 ++++++
.../foundation/src/component-styles.mjs | 33 ++++++
.../foundation/src/graphql-client.mjs | 4 +-
viewers/webcomponents/foundation/src/index.mjs | 1 +
.../foundation/src/menubar-element.mjs | 6 ++
.../foundation/src/object-context-controller.mjs | 41 ++++++-
viewers/webcomponents/foundation/src/register.mjs | 2 +
viewers/webcomponents/foundation/src/theme.css | 24 ++---
.../foundation/test/component-styles.test.mjs | 14 +++
.../foundation/test/components.test.mjs | 40 +++++++
.../foundation/test/domain-components.test.mjs | 1 +
.../foundation/test/element-prefix.test.mjs | 2 +-
.../test/fixtures/rich-schema-fixture.mjs | 14 ++-
.../foundation/test/object-context.test.mjs | 56 ++++++++++
.../sample-htmx-petclinic/README.adoc | 6 +-
.../sample/htmx/petclinic/domain/Pet.java | 3 +-
.../sample/htmx/petclinic/domain/Visit.java | 3 +-
.../webcomponents/pages/petclinic.Pet.html | 1 +
.../webcomponents/pages/petclinic.PetOwner.html | 1 +
.../webcomponents/pages/petclinic.Visit.html | 1 +
.../petclinic/PetClinicHtmxPlaywrightTest.java | 66 +++++++++++-
.../PetClinicHtmxApplication_IntegTest.java | 42 ++++++++
52 files changed, 1641 insertions(+), 64 deletions(-)
copy openspec/changes/archive/{2026-08-29-property-presentation-overrides => 2026-08-29-add-breadcrumbs-web-component}/.openspec.yaml (100%)
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/design.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/proposal.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/domain-web-components/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/tasks.md
copy openspec/changes/archive/{2026-08-29-property-presentation-overrides => 2026-08-29-remove-field-double-borders}/.openspec.yaml (100%)
create mode 100644 openspec/changes/archive/2026-08-29-remove-field-double-borders/design.md
create mode 100644 openspec/changes/archive/2026-08-29-remove-field-double-borders/proposal.md
create mode 100644 openspec/changes/archive/2026-08-29-remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-remove-field-double-borders/tasks.md
create mode 100644 openspec/specs/rich-graphql-navigable-breadcrumbs/spec.md
create mode 100644 viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
copy viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/{rich/query/RichMemberMetadataTest.java => common/query/meta/CommonMetaBreadcrumbsTest.java} (55%)
create mode 100644 viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
create mode 100644 viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
create mode 100644 viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
(causeway) 04/07: docs(openspec): archive breadcrumbs web component
Posted by da...@apache.org.
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 74ec13e39902b66911ef474bc77854a67ffa940c
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:34:39 2026 +0200
docs(openspec): archive breadcrumbs web component
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../specs/domain-web-components/spec.md | 0
.../generic-htmx-web-component-viewer/spec.md | 0
.../specs/graphql-web-component-context/spec.md | 0
.../rich-graphql-navigable-breadcrumbs/spec.md | 0
.../tasks.md | 0
openspec/specs/domain-web-components/spec.md | 56 ++++++++++++++++++++++
.../generic-htmx-web-component-viewer/spec.md | 34 +++++++++++++
.../specs/graphql-web-component-context/spec.md | 39 +++++++++++++++
.../rich-graphql-navigable-breadcrumbs/spec.md | 8 +++-
12 files changed, 136 insertions(+), 1 deletion(-)
diff --git a/openspec/changes/add-breadcrumbs-web-component/.openspec.yaml b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/.openspec.yaml
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/.openspec.yaml
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/.openspec.yaml
diff --git a/openspec/changes/add-breadcrumbs-web-component/design.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/design.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/design.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/design.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/proposal.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/proposal.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/proposal.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/proposal.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/domain-web-components/spec.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/domain-web-components/spec.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/specs/domain-web-components/spec.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/domain-web-components/spec.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
similarity index 100%
copy from openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
copy to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
diff --git a/openspec/changes/add-breadcrumbs-web-component/tasks.md b/openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/tasks.md
similarity index 100%
rename from openspec/changes/add-breadcrumbs-web-component/tasks.md
rename to openspec/changes/archive/2026-08-29-add-breadcrumbs-web-component/tasks.md
diff --git a/openspec/specs/domain-web-components/spec.md b/openspec/specs/domain-web-components/spec.md
index 0f7c872aaed..10ffc1e0eb5 100644
--- a/openspec/specs/domain-web-components/spec.md
+++ b/openspec/specs/domain-web-components/spec.md
@@ -1588,3 +1588,59 @@ The Petclinic sample SHALL demonstrate annotation-derived and HTML-overridden pr
- **WHEN** the Petclinic owner page reaches its ready state
- **THEN** the selected annotation-derived and HTML-overridden names, descriptions, multiline fields, and label positions are visible and accessible
- **AND** non-overridden properties retain their metadata-driven or fallback presentation
+
+### Requirement: Semantic object breadcrumbs
+The component library SHALL provide a framework-neutral `<cw-breadcrumbs>` custom element that consumes the nearest object context's semantic breadcrumbs requirement and renders the navigable ancestor chain followed by the current object.
+
+#### Scenario: Object has multiple ancestors
+- **WHEN** `<cw-breadcrumbs>` receives root-to-parent entries and current-object metadata
+- **THEN** it renders each ancestor as a semantic `<cw-object-link>` in the supplied order
+- **AND** renders the escaped current title as the final non-link item marked `aria-current="page"`
+
+#### Scenario: Object has no ancestors
+- **WHEN** the breadcrumb list is empty and current-object metadata is available
+- **THEN** the component renders a stable breadcrumb landmark containing only the current item
+
+#### Scenario: Ancestor entry is malformed
+- **WHEN** an entry lacks a logical type name, identifier, or title
+- **THEN** the component omits that entry defensively
+- **AND** does not construct a partial navigation target
+
+### Requirement: Accessible breadcrumb presentation
+`<cw-breadcrumbs>` SHALL render keyboard-operable, escaped, responsive light-DOM markup with a `nav` landmark labelled `Breadcrumb`, an ordered list, documented semantic classes, and ordinary link focus behavior.
+
+#### Scenario: Assistive technology inspects breadcrumbs
+- **WHEN** breadcrumb state is ready
+- **THEN** the navigation landmark and list communicate hierarchy order
+- **AND** exactly one final current item exposes `aria-current="page"`
+
+#### Scenario: Breadcrumb title is long or contains markup-like text
+- **WHEN** an ancestor or current title contains long text or HTML-significant characters
+- **THEN** the title is escaped as text and wraps without causing horizontal page overflow
+
+### Requirement: Existing semantic navigation ownership
+Ancestor activation SHALL use the existing bubbling and composed `causeway-navigation-request` contract produced by `<cw-object-link>` and SHALL NOT impose a URL, router, HTMX, or application callback API.
+
+#### Scenario: User activates an ancestor
+- **WHEN** the user follows an ancestor breadcrumb by pointer or keyboard
+- **THEN** one semantic navigation event identifies that ancestor's logical type, id, and title
+- **AND** the host remains responsible for route handling
+
+### Requirement: Local breadcrumb states
+`<cw-breadcrumbs>` SHALL render loading, unsupported, partial-error, and terminal-error states locally without suppressing successful sibling components.
+
+#### Scenario: Breadcrumb traversal is loading
+- **WHEN** the semantic requirement is awaiting schema or object data
+- **THEN** the component exposes a bounded busy status within its own landmark
+
+#### Scenario: Breadcrumb traversal fails
+- **WHEN** the requirement reports unsupported, partial-error, or terminal-error state
+- **THEN** the component renders a bounded accessible local diagnostic
+- **AND** does not render stale ancestor links
+
+### Requirement: Public breadcrumb component contract
+The component registry, ECMAScript exports, semantic element-name constants, stylesheet surfaces, usage documentation, fixtures, and automation hooks SHALL include `<cw-breadcrumbs>` without changing existing component contracts.
+
+#### Scenario: Application uses plain HTML
+- **WHEN** an application imports the standard foundation entry module and writes `<cw-breadcrumbs>` beneath an object context
+- **THEN** the element is registered and operates without a frontend framework or direct GraphQL document construction
diff --git a/openspec/specs/generic-htmx-web-component-viewer/spec.md b/openspec/specs/generic-htmx-web-component-viewer/spec.md
index 048f4cc9b35..303bb5e028c 100644
--- a/openspec/specs/generic-htmx-web-component-viewer/spec.md
+++ b/openspec/specs/generic-htmx-web-component-viewer/spec.md
@@ -605,3 +605,37 @@ The resolved component policy SHALL govern eligible references, editors, read-on
- **WHEN** configuration supplies a component-toolkit value other than `vaadin` or `native`
- **THEN** configuration binding rejects it with a bounded error
- **AND** the viewer does not silently select a broader policy
+
+### Requirement: Petclinic navigable breadcrumbs demonstration
+The Petclinic HTMX sample SHALL demonstrate `<cw-breadcrumbs>` through standard navigable-parent annotations and HTML-authored page composition without application-specific breadcrumb rendering or route code.
+
+#### Scenario: Pet owner page is rendered
+- **WHEN** the Pet Owner custom page renders for Mary Smith
+- **THEN** its breadcrumb landmark contains Mary Smith as the current item with no ancestor link
+
+#### Scenario: Pet page is rendered
+- **WHEN** Mary Smith's pet Basil is rendered
+- **THEN** the breadcrumb landmark contains a Mary Smith ancestor link followed by Basil as the current item
+- **AND** the hierarchy is derived from `Pet.petOwner` marked `Navigable.PARENT`
+
+#### Scenario: Visit page is rendered
+- **WHEN** Basil's scheduled visit is rendered
+- **THEN** the breadcrumb landmark contains Mary Smith and Basil ancestor links in that order followed by the visit as the current item
+- **AND** the hierarchy is derived through `Visit.pet` and then `Pet.petOwner`
+
+#### Scenario: User follows a breadcrumb
+- **WHEN** a user activates the Mary Smith or Basil ancestor link from a descendant route
+- **THEN** the established HTMX semantic navigation bridge installs the corresponding canonical object route
+- **AND** no breadcrumb-specific route handler or URL construction is required
+
+### Requirement: Petclinic breadcrumb regression coverage
+Petclinic integration and browser acceptance SHALL verify navigable annotations, rich GraphQL breadcrumb metadata, component rendering, accessibility, responsive presentation, and canonical navigation while retaining existing custom-page behavior.
+
+#### Scenario: Rich GraphQL hierarchy is queried
+- **WHEN** integration coverage reads metadata for owner, pet, and visit fixtures
+- **THEN** it observes deterministic zero-, one-, and two-ancestor breadcrumb chains
+
+#### Scenario: Browser exercises descendant hierarchy
+- **WHEN** browser automation opens pet and visit routes at desktop and mobile widths
+- **THEN** breadcrumb order, current state, focusable links, no-overflow presentation, and navigation are correct
+- **AND** no unexpected console, page, resource, or GraphQL failure is observed
diff --git a/openspec/specs/graphql-web-component-context/spec.md b/openspec/specs/graphql-web-component-context/spec.md
index 15612fbf98d..b8d35e63194 100644
--- a/openspec/specs/graphql-web-component-context/spec.md
+++ b/openspec/specs/graphql-web-component-context/spec.md
@@ -194,3 +194,42 @@ The Maven build SHALL exercise the runnable sample application against its packa
- **WHEN** the sample integration test calls the running `/graphql` endpoint
- **THEN** targeted standard introspection resolves the sample object's generated rich-schema types
- **AND** a rich-schema object lookup returns the deterministic object's metadata and property state
+
+### Requirement: Semantic breadcrumbs requirement
+The object context SHALL accept a semantic breadcrumbs read requirement and SHALL translate it to the supported current-object identity, title, and navigable-ancestor metadata fields without exposing generated GraphQL names to the consumer.
+
+#### Scenario: Breadcrumb consumer joins initial composition
+- **WHEN** a breadcrumbs consumer and other domain components register during the same rendering turn
+- **THEN** the context coalesces their selections into one initial object operation
+- **AND** breadcrumb metadata is returned through the consumer's local requirement state
+
+#### Scenario: Breadcrumb consumer connects later
+- **WHEN** breadcrumbs are registered after an object snapshot already exists without their selection
+- **THEN** the context loads only the missing metadata selection
+- **AND** merges it into the immutable current snapshot
+
+#### Scenario: Breadcrumb consumer disconnects
+- **WHEN** the consumer releases its requirement
+- **THEN** the requirement is omitted from subsequent complete refresh projections
+- **AND** release alone causes no network request
+
+### Requirement: Targeted breadcrumb schema discovery
+The GraphQL client SHALL discover the shared breadcrumb-entry object type through targeted introspection only when it is reachable from requested object metadata.
+
+#### Scenario: Metadata supports breadcrumb entries
+- **WHEN** an object description exposes `breadcrumbs` as a list of breadcrumb-entry objects
+- **THEN** targeted discovery loads that entry type and its supported fields
+- **AND** the semantic requirement selects only supported identity and title fields
+
+#### Scenario: Schema lacks breadcrumb support
+- **WHEN** the introspected metadata type has no `breadcrumbs` field or no usable entry identity fields
+- **THEN** the breadcrumb requirement reports an unsupported local state
+- **AND** existing header and member requirements remain usable
+
+### Requirement: Breadcrumb partial-error isolation
+The object context SHALL associate a GraphQL error on navigable breadcrumb metadata with the breadcrumbs requirement while preserving successful sibling metadata and member state.
+
+#### Scenario: Ancestor traversal fails
+- **WHEN** the object response contains current metadata and member data plus an error at the breadcrumb field path
+- **THEN** the breadcrumbs consumer receives a partial-error state
+- **AND** successful header, property, action, and collection consumers retain their normal state
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md b/openspec/specs/rich-graphql-navigable-breadcrumbs/spec.md
similarity index 94%
rename from openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
rename to openspec/specs/rich-graphql-navigable-breadcrumbs/spec.md
index 71d8afa0c08..7f3d60828a6 100644
--- a/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
+++ b/openspec/specs/rich-graphql-navigable-breadcrumbs/spec.md
@@ -1,4 +1,10 @@
-## ADDED Requirements
+# Rich GraphQL Navigable Breadcrumbs Specification
+
+## Purpose
+
+Define the bounded rich GraphQL metadata contract that projects an object's metamodel-authoritative navigable ancestors for framework-neutral breadcrumb consumers.
+
+## Requirements
### Requirement: Navigable ancestor metadata
Each rich GraphQL object metadata type SHALL expose a nullable `breadcrumbs` field containing the current object's navigable ancestors as shared breadcrumb-entry objects with non-null `logicalTypeName`, `id`, and `title` fields.
(causeway) 02/07: feat(webcomponents): add navigable breadcrumbs
Posted by da...@apache.org.
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 a1b92533d5121eee424a9eedf48ef5857ab119bb
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:27:32 2026 +0200
feat(webcomponents): add navigable breadcrumbs
---
.../changes/add-breadcrumbs-web-component/tasks.md | 44 ++++----
.../model/domain/common/query/CommonMeta.java | 5 +
.../common/query/meta/CommonMetaBreadcrumbs.java | 74 +++++++++++++
.../common/query/meta/CommonMetaFetcher.java | 76 +++++++++++++
.../query/meta/CommonMetaBreadcrumbsTest.java | 79 ++++++++++++++
.../common/query/meta/CommonMetaFetcherTest.java | 120 +++++++++++++++++++++
.../viewer/test/domain/dept/StaffMember.java | 25 ++++-
...adata_is_additive_facet_driven_and_shared._.gql | 29 +++++
.../test/e2e/special/MemberMetadata_IntegTest.java | 31 ++++++
viewers/graphql/test/src/test/resources/schema.gql | 117 ++++++++++++++++++++
viewers/webcomponents/foundation/USAGE.adoc | 15 ++-
.../foundation/src/breadcrumbs-element.mjs | 62 +++++++++++
.../foundation/src/component-contracts.mjs | 2 +
.../foundation/src/component-styles.css | 33 ++++++
.../foundation/src/component-styles.mjs | 33 ++++++
.../foundation/src/graphql-client.mjs | 4 +-
viewers/webcomponents/foundation/src/index.mjs | 1 +
.../foundation/src/object-context-controller.mjs | 41 ++++++-
viewers/webcomponents/foundation/src/register.mjs | 2 +
.../foundation/test/components.test.mjs | 40 +++++++
.../foundation/test/domain-components.test.mjs | 1 +
.../foundation/test/element-prefix.test.mjs | 2 +-
.../test/fixtures/rich-schema-fixture.mjs | 14 ++-
.../foundation/test/object-context.test.mjs | 56 ++++++++++
.../sample-htmx-petclinic/README.adoc | 6 +-
.../sample/htmx/petclinic/domain/Pet.java | 3 +-
.../sample/htmx/petclinic/domain/Visit.java | 3 +-
.../webcomponents/pages/petclinic.Pet.html | 1 +
.../webcomponents/pages/petclinic.PetOwner.html | 1 +
.../webcomponents/pages/petclinic.Visit.html | 1 +
.../petclinic/PetClinicHtmxPlaywrightTest.java | 35 +++++-
.../PetClinicHtmxApplication_IntegTest.java | 42 ++++++++
32 files changed, 960 insertions(+), 38 deletions(-)
diff --git a/openspec/changes/add-breadcrumbs-web-component/tasks.md b/openspec/changes/add-breadcrumbs-web-component/tasks.md
index f32917cafed..8441d41a77b 100644
--- a/openspec/changes/add-breadcrumbs-web-component/tasks.md
+++ b/openspec/changes/add-breadcrumbs-web-component/tasks.md
@@ -1,36 +1,36 @@
## 1. Rich GraphQL breadcrumb metadata
-- [ ] 1.1 Add one shared GraphQL breadcrumb-entry type and an optional `breadcrumbs` field to generated rich object metadata.
-- [ ] 1.2 Implement root-to-parent traversal through `ObjectSpecification.getNavigableParent`, with bookmark identity, request-local titles, a 32-ancestor bound, cycle detection, and safe failures.
-- [ ] 1.3 Add GraphQL model unit tests for empty, single, multi-level, unbookmarkable, cyclic, over-depth, and throwing parent chains.
-- [ ] 1.4 Extend GraphQL schema and end-to-end contract fixtures to verify additive field shape, shared-type reuse, partial errors, and no traversal when unselected.
+- [x] 1.1 Add one shared GraphQL breadcrumb-entry type and an optional `breadcrumbs` field to generated rich object metadata.
+- [x] 1.2 Implement root-to-parent traversal through `ObjectSpecification.getNavigableParent`, with bookmark identity, request-local titles, a 32-ancestor bound, cycle detection, and safe failures.
+- [x] 1.3 Add GraphQL model unit tests for empty, single, multi-level, unbookmarkable, cyclic, over-depth, and throwing parent chains.
+- [x] 1.4 Extend GraphQL schema and end-to-end contract fixtures to verify additive field shape, shared-type reuse, partial errors, and no traversal when unselected.
## 2. Browser context support
-- [ ] 2.1 Extend targeted metadata introspection to discover the shared breadcrumb-entry type without broad schema introspection.
-- [ ] 2.2 Add the semantic breadcrumbs requirement, nested selection translation, state mapping, projection coalescing, delta loading, refresh, and release behavior to `ObjectContextController`.
-- [ ] 2.3 Add browser context and operation tests for supported, unsupported, ready, partial-error, late-registration, and released breadcrumb requirements.
-- [ ] 2.4 Update rich-schema fixtures with deterministic empty, one-level, and multi-level breadcrumb metadata.
+- [x] 2.1 Extend targeted metadata introspection to discover the shared breadcrumb-entry type without broad schema introspection.
+- [x] 2.2 Add the semantic breadcrumbs requirement, nested selection translation, state mapping, projection coalescing, delta loading, refresh, and release behavior to `ObjectContextController`.
+- [x] 2.3 Add browser context and operation tests for supported, unsupported, ready, partial-error, late-registration, and released breadcrumb requirements.
+- [x] 2.4 Update rich-schema fixtures with deterministic empty, one-level, and multi-level breadcrumb metadata.
## 3. Breadcrumb web component
-- [ ] 3.1 Implement and export `<cw-breadcrumbs>` as a context consumer rendering ancestor object links and one escaped current item in an accessible breadcrumb landmark.
-- [ ] 3.2 Register the element and add its public element-name, host-class, stable selector, and semantic navigation contracts.
-- [ ] 3.3 Add responsive breadcrumb styles for ordered separators, focus, wrapping, long titles, narrow layouts, dark mode, and forced colors.
-- [ ] 3.4 Add component tests for loading, empty hierarchy, multiple ancestors, malformed entries, navigation events, escaping, errors, accessibility, and overflow-safe markup.
-- [ ] 3.5 Document plain-HTML usage, GraphQL requirements, hierarchy semantics, error behavior, and framework-neutral navigation ownership.
+- [x] 3.1 Implement and export `<cw-breadcrumbs>` as a context consumer rendering ancestor object links and one escaped current item in an accessible breadcrumb landmark.
+- [x] 3.2 Register the element and add its public element-name, host-class, stable selector, and semantic navigation contracts.
+- [x] 3.3 Add responsive breadcrumb styles for ordered separators, focus, wrapping, long titles, narrow layouts, dark mode, and forced colors.
+- [x] 3.4 Add component tests for loading, empty hierarchy, multiple ancestors, malformed entries, navigation events, escaping, errors, accessibility, and overflow-safe markup.
+- [x] 3.5 Document plain-HTML usage, GraphQL requirements, hierarchy semantics, error behavior, and framework-neutral navigation ownership.
## 4. Petclinic adoption
-- [ ] 4.1 Mark `Pet.petOwner` and `Visit.pet` with the standard `Navigable.PARENT` property-layout semantic.
-- [ ] 4.2 Compose `<cw-breadcrumbs>` into the Pet Owner, Pet, and Visit HTML-authored pages without adding application-specific breadcrumb code.
-- [ ] 4.3 Extend Petclinic GraphQL integration coverage for zero-, one-, and two-ancestor fixture chains and private-page markup coverage for the component.
-- [ ] 4.4 Extend Petclinic Playwright coverage for breadcrumb order, current state, keyboard activation, canonical HTMX navigation, responsive layout, accessibility, and clean browser diagnostics.
-- [ ] 4.5 Update Petclinic documentation and screenshots or stable test hooks where the existing sample contract requires them.
+- [x] 4.1 Mark `Pet.petOwner` and `Visit.pet` with the standard `Navigable.PARENT` property-layout semantic.
+- [x] 4.2 Compose `<cw-breadcrumbs>` into the Pet Owner, Pet, and Visit HTML-authored pages without adding application-specific breadcrumb code.
+- [x] 4.3 Extend Petclinic GraphQL integration coverage for zero-, one-, and two-ancestor fixture chains and private-page markup coverage for the component.
+- [x] 4.4 Extend Petclinic Playwright coverage for breadcrumb order, current state, keyboard activation, canonical HTMX navigation, responsive layout, accessibility, and clean browser diagnostics.
+- [x] 4.5 Update Petclinic documentation and screenshots or stable test hooks where the existing sample contract requires them.
## 5. Validation and archive readiness
-- [ ] 5.1 Run focused GraphQL model tests and GraphQL end-to-end tests for breadcrumb metadata.
-- [ ] 5.2 Run foundation unit, component, fixture, lint, and package verification suites.
-- [ ] 5.3 Run HTMX viewer, Petclinic integration, and Petclinic Playwright acceptance suites.
-- [ ] 5.4 Run applicable Maven verification, OpenSpec strict validation, formatting, license, diff, and working-tree checks.
+- [x] 5.1 Run focused GraphQL model tests and GraphQL end-to-end tests for breadcrumb metadata.
+- [x] 5.2 Run foundation unit, component, fixture, lint, and package verification suites.
+- [x] 5.3 Run HTMX viewer, Petclinic integration, and Petclinic Playwright acceptance suites.
+- [x] 5.4 Run applicable Maven verification, OpenSpec strict validation, formatting, license, diff, and working-tree checks.
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
index 0370fe66686..18d26fa5568 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/CommonMeta.java
@@ -26,6 +26,7 @@
import org.apache.causeway.viewer.graphql.model.domain.ElementCustom;
import org.apache.causeway.viewer.graphql.model.domain.TypeNames;
import org.apache.causeway.viewer.graphql.model.domain.common.interactors.ObjectInteractor;
+import org.apache.causeway.viewer.graphql.model.domain.common.query.meta.CommonMetaBreadcrumbs;
import org.apache.causeway.viewer.graphql.model.domain.common.query.meta.CommonMetaCssClass;
import org.apache.causeway.viewer.graphql.model.domain.common.query.meta.CommonMetaFetcher;
import org.apache.causeway.viewer.graphql.model.domain.common.query.meta.CommonMetaGrid;
@@ -44,6 +45,7 @@ public class CommonMeta extends ElementCustom {
private final CommonMetaLogicalTypeName metaLogicalTypeName;
private final CommonMetaVersion metaVersion;
private final CommonMetaTitle metaTitle;
+ private final CommonMetaBreadcrumbs metaBreadcrumbs;
private final CommonMetaIcon metaIcon;
private final CommonMetaCssClass metaCssClass;
private final CommonMetaLayout metaLayout;
@@ -66,6 +68,7 @@ public CommonMeta(
this.metaLogicalTypeName = null;
this.metaVersion = null;
this.metaTitle = null;
+ this.metaBreadcrumbs = null;
this.metaIcon = null;
this.metaCssClass = null;
this.metaLayout = null;
@@ -78,6 +81,7 @@ public CommonMeta(
addChildFieldFor(this.metaLogicalTypeName = new CommonMetaLogicalTypeName(context));
addChildFieldFor(this.metaVersion = isEntity() ? new CommonMetaVersion(context) : null);
addChildFieldFor(this.metaTitle = new CommonMetaTitle(context));
+ addChildFieldFor(this.metaBreadcrumbs = new CommonMetaBreadcrumbs(context));
addChildFieldFor(this.metaCssClass = new CommonMetaCssClass(context));
addChildFieldFor(this.metaLayout = new CommonMetaLayout(context));
addChildFieldFor(this.metaSaveAs = new CommonMetaSaveAs(context));
@@ -110,6 +114,7 @@ protected void addDataFetchersForChildren() {
metaVersion.addDataFetcher(this);
}
metaTitle.addDataFetcher(this);
+ metaBreadcrumbs.addDataFetcher(this);
metaCssClass.addDataFetcher(this);
metaLayout.addDataFetcher(this);
metaSaveAs.addDataFetcher(this);
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
new file mode 100644
index 00000000000..8679140d619
--- /dev/null
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbs.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.graphql.model.domain.common.query.meta;
+
+import java.util.List;
+import java.util.Map;
+
+import graphql.Scalars;
+import graphql.schema.DataFetchingEnvironment;
+import graphql.schema.GraphQLList;
+import graphql.schema.GraphQLNonNull;
+import graphql.schema.GraphQLObjectType;
+
+import org.apache.causeway.viewer.graphql.model.context.Context;
+import org.apache.causeway.viewer.graphql.model.domain.Element;
+
+import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition;
+
+public class CommonMetaBreadcrumbs extends Element {
+
+ static final String ENTRY_TYPE_NAME = "RichNavigableBreadcrumb";
+
+ public CommonMetaBreadcrumbs(final Context context) {
+ super(context);
+ setField(newFieldDefinition()
+ .name("breadcrumbs")
+ .description("Navigable ancestors ordered from root to immediate parent.")
+ .type(GraphQLList.list(GraphQLNonNull.nonNull(entryType(context))))
+ .build());
+ }
+
+ private static GraphQLObjectType entryType(final Context context) {
+ final var existing = context.graphQLTypeRegistry.lookup(ENTRY_TYPE_NAME, GraphQLObjectType.class);
+ if (existing.isPresent()) {
+ return existing.get();
+ }
+ final var type = GraphQLObjectType.newObject()
+ .name(ENTRY_TYPE_NAME)
+ .description("Bookmark identity and title for one navigable ancestor.")
+ .field(newFieldDefinition()
+ .name("logicalTypeName")
+ .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
+ .field(newFieldDefinition()
+ .name("id")
+ .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
+ .field(newFieldDefinition()
+ .name("title")
+ .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)))
+ .build();
+ context.graphQLTypeRegistry.addTypeIfNotAlreadyPresent(type);
+ return context.graphQLTypeRegistry.lookup(ENTRY_TYPE_NAME, GraphQLObjectType.class).orElse(type);
+ }
+
+ @Override
+ protected List<Map<String, String>> fetchData(final DataFetchingEnvironment environment) {
+ return environment.<CommonMetaFetcher>getSource().breadcrumbs();
+ }
+}
diff --git a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
index cf737114bc4..906bd67a33d 100644
--- a/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
+++ b/viewers/graphql/model/src/main/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcher.java
@@ -18,7 +18,13 @@
*/
package org.apache.causeway.viewer.graphql.model.domain.common.query.meta;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.function.Function;
import org.apache.causeway.applib.services.bookmark.Bookmark;
import org.apache.causeway.applib.services.bookmark.BookmarkService;
@@ -36,6 +42,8 @@
*/
public class CommonMetaFetcher {
+ static final int MAXIMUM_BREADCRUMB_ANCESTORS = 32;
+
private final Bookmark bookmark;
private final BookmarkService bookmarkService;
private final ObjectManager objectManager;
@@ -87,6 +95,70 @@ public String title() {
.orElse(null);
}
+ public List<Map<String, String>> breadcrumbs() {
+ return managedObject()
+ .map(current -> traverseBreadcrumbs(
+ bookmark,
+ current.getPojo(),
+ pojo -> objectManager.adapt(pojo).objSpec().getNavigableParent(pojo),
+ bookmarkService::bookmarkFor,
+ pojo -> objectManager.adapt(pojo).getTitle()))
+ .orElseGet(List::of);
+ }
+
+ static List<Map<String, String>> traverseBreadcrumbs(
+ final Bookmark currentBookmark,
+ final Object currentPojo,
+ final Function<Object, Object> parentResolver,
+ final Function<Object, Optional<Bookmark>> bookmarkResolver,
+ final Function<Object, String> titleResolver) {
+ final var seen = new HashSet<Bookmark>();
+ seen.add(currentBookmark);
+ final var ancestors = new ArrayList<Map<String, String>>();
+ var pojo = currentPojo;
+ while (true) {
+ final Object parentPojo;
+ try {
+ parentPojo = parentResolver.apply(pojo);
+ } catch (RuntimeException ex) {
+ throw breadcrumbFailure("GRAPHQL_BREADCRUMB_PARENT_FAILED", "Navigable parent evaluation failed.");
+ }
+ if (parentPojo == null) {
+ break;
+ }
+ final Optional<Bookmark> parentBookmark;
+ try {
+ parentBookmark = bookmarkResolver.apply(parentPojo);
+ } catch (RuntimeException ex) {
+ throw breadcrumbFailure("GRAPHQL_BREADCRUMB_IDENTITY_FAILED", "Navigable parent identity resolution failed.");
+ }
+ if (parentBookmark.isEmpty()) {
+ break;
+ }
+ if (!seen.add(parentBookmark.get())) {
+ throw breadcrumbFailure("GRAPHQL_BREADCRUMB_CYCLE", "Navigable parent hierarchy contains a cycle.");
+ }
+ if (ancestors.size() >= MAXIMUM_BREADCRUMB_ANCESTORS) {
+ throw breadcrumbFailure(
+ "GRAPHQL_BREADCRUMB_DEPTH_EXCEEDED",
+ "Navigable parent hierarchy exceeds " + MAXIMUM_BREADCRUMB_ANCESTORS + " ancestors.");
+ }
+ final String title;
+ try {
+ title = java.util.Objects.requireNonNull(titleResolver.apply(parentPojo));
+ } catch (RuntimeException ex) {
+ throw breadcrumbFailure("GRAPHQL_BREADCRUMB_TITLE_FAILED", "Navigable parent title resolution failed.");
+ }
+ ancestors.add(Map.of(
+ "logicalTypeName", parentBookmark.get().logicalTypeName(),
+ "id", parentBookmark.get().identifier(),
+ "title", title));
+ pojo = parentPojo;
+ }
+ Collections.reverse(ancestors);
+ return List.copyOf(ancestors);
+ }
+
public String cssClass() {
return managedObject()
.map(managedObject -> {
@@ -127,4 +199,8 @@ private Optional<ManagedObject> managedObject() {
return bookmarkService.lookup(bookmark)
.map(objectManager::adapt);
}
+
+ private static IllegalStateException breadcrumbFailure(final String code, final String message) {
+ return new IllegalStateException(code + ": " + message);
+ }
}
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java
new file mode 100644
index 00000000000..132d3268ea9
--- /dev/null
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaBreadcrumbsTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.graphql.model.domain.common.query.meta;
+
+import jakarta.inject.Provider;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+
+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.objectmanager.ObjectManager;
+import org.apache.causeway.core.metamodel.specloader.SpecificationLoader;
+import org.apache.causeway.viewer.graphql.model.application.ApplicationEntryService;
+import org.apache.causeway.viewer.graphql.model.context.Context;
+import org.apache.causeway.viewer.graphql.model.registry.GraphQLTypeRegistry;
+import org.apache.causeway.viewer.graphql.model.types.TypeMapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+class CommonMetaBreadcrumbsTest {
+
+ @Test
+ void usesOneSharedEntryTypeWithRequiredIdentityAndTitle() {
+ final var context = context();
+ final var first = new CommonMetaBreadcrumbs(context);
+ final var second = new CommonMetaBreadcrumbs(context);
+
+ assertThat(first.getField().getName()).isEqualTo("breadcrumbs");
+ assertThat(first.getField().getType().toString()).isEqualTo("[RichNavigableBreadcrumb!]");
+ assertThat(second.getField().getType().toString()).isEqualTo(first.getField().getType().toString());
+ assertThat(context.graphQLTypeRegistry.getGraphQLTypes())
+ .filteredOn(type -> type.toString().contains(CommonMetaBreadcrumbs.ENTRY_TYPE_NAME))
+ .hasSize(1);
+ final var entry = context.graphQLTypeRegistry.lookup(
+ CommonMetaBreadcrumbs.ENTRY_TYPE_NAME,
+ graphql.schema.GraphQLObjectType.class).orElseThrow();
+ assertThat(entry.getFieldDefinitions())
+ .extracting(field -> field.getName() + ":" + field.getType())
+ .containsExactly(
+ "logicalTypeName:String!",
+ "id:String!",
+ "title:String!");
+ }
+
+ private static Context context() {
+ @SuppressWarnings("unchecked")
+ final Provider<Context> contextProvider = mock(Provider.class);
+ return new Context(
+ mock(BookmarkService.class),
+ mock(SpecificationLoader.class),
+ mock(TypeMapper.class),
+ mock(ServiceRegistry.class),
+ mock(CausewayConfiguration.class, Answers.RETURNS_DEEP_STUBS),
+ mock(CausewaySystemEnvironment.class),
+ mock(ObjectManager.class),
+ new GraphQLTypeRegistry(contextProvider),
+ mock(ApplicationEntryService.class));
+ }
+}
diff --git a/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
new file mode 100644
index 00000000000..5d5aad04e3b
--- /dev/null
+++ b/viewers/graphql/model/src/test/java/org/apache/causeway/viewer/graphql/model/domain/common/query/meta/CommonMetaFetcherTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.graphql.model.domain.common.query.meta;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CommonMetaFetcherTest {
+
+ @Test
+ void returnsEmptySingleAndRootOrderedAncestorChains() {
+ final var bookmarks = Map.of(
+ "owner", bookmark("Owner", "owner-1"),
+ "pet", bookmark("Pet", "pet-1"),
+ "visit", bookmark("Visit", "visit-1"));
+ assertThat(traverse("owner", Map.of(), bookmarks)).isEmpty();
+ assertThat(traverse("pet", Map.of("pet", "owner"), bookmarks))
+ .containsExactly(Map.of(
+ "logicalTypeName", "test.Owner",
+ "id", "owner-1",
+ "title", "Title owner"));
+ assertThat(traverse("visit", Map.of("visit", "pet", "pet", "owner"), bookmarks))
+ .extracting(entry -> entry.get("id"))
+ .containsExactly("owner-1", "pet-1");
+ }
+
+ @Test
+ void stopsBeforeAnUnbookmarkableParent() {
+ assertThat(CommonMetaFetcher.traverseBreadcrumbs(
+ bookmark("Child", "child-1"),
+ "child",
+ pojo -> "child".equals(pojo) ? "transient" : null,
+ pojo -> Optional.empty(),
+ pojo -> "secret transient title"))
+ .isEmpty();
+ }
+
+ @Test
+ void rejectsCyclesDepthOverflowAndThrowingParentWithoutSensitiveDetails() {
+ final var cyclicParents = Map.of("child", "parent", "parent", "child");
+ assertThatThrownBy(() -> CommonMetaFetcher.traverseBreadcrumbs(
+ bookmark("Child", "child-1"),
+ "child",
+ cyclicParents::get,
+ pojo -> Optional.of(bookmark(capitalize((String) pojo), pojo + "-1")),
+ pojo -> "Title " + pojo))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("GRAPHQL_BREADCRUMB_CYCLE");
+
+ final var parents = new HashMap<Object, Object>();
+ for (int index = 0; index <= CommonMetaFetcher.MAXIMUM_BREADCRUMB_ANCESTORS; index++) {
+ parents.put(index, index + 1);
+ }
+ assertThatThrownBy(() -> CommonMetaFetcher.traverseBreadcrumbs(
+ bookmark("Node", "0"),
+ 0,
+ parents::get,
+ pojo -> Optional.of(bookmark("Node", pojo.toString())),
+ pojo -> "Node " + pojo))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("GRAPHQL_BREADCRUMB_DEPTH_EXCEEDED")
+ .hasMessageContaining("32");
+
+ assertThatThrownBy(() -> CommonMetaFetcher.traverseBreadcrumbs(
+ bookmark("Child", "child-1"),
+ "child",
+ pojo -> {
+ throw new IllegalArgumentException("secret domain exception");
+ },
+ pojo -> Optional.empty(),
+ Object::toString))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("GRAPHQL_BREADCRUMB_PARENT_FAILED")
+ .hasMessageNotContaining("secret domain exception");
+ }
+
+ private static java.util.List<Map<String, String>> traverse(
+ final String current,
+ final Map<String, String> parents,
+ final Map<String, Bookmark> bookmarks) {
+ return CommonMetaFetcher.traverseBreadcrumbs(
+ bookmarks.get(current),
+ current,
+ parents::get,
+ pojo -> Optional.ofNullable(bookmarks.get(pojo)),
+ pojo -> "Title " + pojo);
+ }
+
+ private static Bookmark bookmark(final String type, final String id) {
+ return Bookmark.forLogicalTypeNameAndIdentifier("test." + type, id);
+ }
+
+ private static String capitalize(final String value) {
+ return Character.toUpperCase(value.charAt(0)) + value.substring(1);
+ }
+}
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
index 45316dbd28d..12409ed089a 100644
--- a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/domain/dept/StaffMember.java
@@ -20,6 +20,7 @@
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
+import java.util.concurrent.atomic.AtomicInteger;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
@@ -38,6 +39,7 @@
import org.apache.causeway.applib.annotation.Editing;
import org.apache.causeway.applib.annotation.LabelPosition;
import org.apache.causeway.applib.annotation.Nature;
+import org.apache.causeway.applib.annotation.Navigable;
import org.apache.causeway.applib.annotation.Optionality;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.PropertyLayout;
@@ -94,12 +96,31 @@ public String validateName(final String proposedName) {
return null;
}
- @Getter @Setter
- @Property
+ private static final AtomicInteger DEPARTMENT_READ_COUNT = new AtomicInteger();
+
@ManyToOne(optional = false)
@JoinColumn(name = "department_id")
private Department department;
+ @Property
+ @PropertyLayout(navigable = Navigable.PARENT)
+ public Department getDepartment() {
+ DEPARTMENT_READ_COUNT.incrementAndGet();
+ return department;
+ }
+
+ public void setDepartment(final Department department) {
+ this.department = department;
+ }
+
+ public static void resetDepartmentReadCount() {
+ DEPARTMENT_READ_COUNT.set(0);
+ }
+
+ public static int departmentReadCount() {
+ return DEPARTMENT_READ_COUNT.get();
+ }
+
@Getter @Setter
@Property(editing = Editing.ENABLED)
private Grade grade;
diff --git a/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
new file mode 100644
index 00000000000..93e7b770eb1
--- /dev/null
+++ b/viewers/graphql/test/src/test/java/org/apache/causeway/viewer/graphql/viewer/test/e2e/special/MemberMetadata_IntegTest.breadcrumb_metadata_is_additive_facet_driven_and_shared._.gql
@@ -0,0 +1,29 @@
+{
+ breadcrumbType: __type(name: "RichNavigableBreadcrumb") {
+ fields {
+ name
+ type {
+ kind
+ name
+ }
+ }
+ }
+ rich {
+ university_dept_Staff {
+ findStaffMemberByName {
+ invoke(name: "Gerry Jones") {
+ results {
+ _meta {
+ title
+ breadcrumbs {
+ logicalTypeName
+ id
+ title
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
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 fb163d328f1..c1ef926a312 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
@@ -30,6 +30,7 @@
import org.apache.causeway.applib.services.bookmark.Bookmark;
import org.apache.causeway.commons.internal.collections._Maps;
import org.apache.causeway.viewer.graphql.viewer.test.domain.dept.Department;
+import org.apache.causeway.viewer.graphql.viewer.test.domain.dept.StaffMember;
import org.apache.causeway.viewer.graphql.viewer.test.e2e.Abstract_IntegTest;
import static org.assertj.core.api.Assertions.assertThat;
@@ -70,8 +71,35 @@ void known_wrapper_introspection_exposes_the_metadata_field() throws Exception {
.contains("metadata", "get", "hidden", "disabled", "validate", "datatype");
}
+ @Test
+ void breadcrumb_metadata_is_additive_facet_driven_and_shared() throws Exception {
+ StaffMember.resetDepartmentReadCount();
+ var root = objectMapper.readTree(submit());
+ var metadata = root.at("/data/rich/university_dept_Staff/findStaffMemberByName/invoke/results/_meta");
+
+ assertThat(root.at("/errors").isMissingNode())
+ .as(root.toPrettyString())
+ .isTrue();
+ assertThat(root.at("/data/breadcrumbType/fields"))
+ .extracting(node -> node.get("name").stringValue())
+ .containsExactlyInAnyOrder("logicalTypeName", "id", "title");
+ assertThat(metadata.get("title").stringValue()).isEqualTo("Untitled Staff Member");
+ assertThat(metadata.get("breadcrumbs"))
+ .singleElement()
+ .satisfies(node -> {
+ assertThat(node.get("logicalTypeName").stringValue())
+ .isEqualTo("university.dept.Department");
+ assertThat(node.get("id").stringValue()).isNotBlank();
+ assertThat(node.get("title").stringValue()).isEqualTo("Untitled Department");
+ });
+ assertThat(StaffMember.departmentReadCount())
+ .as("selected breadcrumb metadata evaluates the navigable parent")
+ .isPositive();
+ }
+
@Test
void reads_known_wrappers_and_preserves_resource_metadata_and_redaction() throws Exception {
+ StaffMember.resetDepartmentReadCount();
var bookmark = transactionService.callTransactional(
Propagation.REQUIRED,
() -> {
@@ -122,5 +150,8 @@ void reads_known_wrappers_and_preserves_resource_metadata_and_redaction() throws
.doesNotContain("CONFIDENTIAL_RESOURCE_CONTENT")
.doesNotContain("disabled-reason")
.doesNotContain("authorization-policy");
+ assertThat(StaffMember.departmentReadCount())
+ .as("unselected breadcrumb metadata must not evaluate the navigable parent")
+ .isZero();
}
}
diff --git a/viewers/graphql/test/src/test/resources/schema.gql b/viewers/graphql/test/src/test/resources/schema.gql
index 7f1c034ea1f..8b56cd737f7 100644
--- a/viewers/graphql/test/src/test/resources/schema.gql
+++ b/viewers/graphql/test/src/test/resources/schema.gql
@@ -173,6 +173,8 @@ type RichMemberMetadata {
description: String
"Canonical translated friendly name."
friendlyName: String!
+ "Canonical label position."
+ labelPosition: String!
"Finite maximum input length, or null."
maxLength: Int
"Requested multiline row count, or null."
@@ -185,6 +187,13 @@ type RichMemberMetadata {
typicalLength: Int
}
+"Bookmark identity and title for one navigable ancestor."
+type RichNavigableBreadcrumb {
+ id: String!
+ logicalTypeName: String!
+ title: String!
+}
+
type SIMPLESchema {
causeway_applib_DomainObjectList(object: simple__causeway_applib_DomainObjectList__gqlv_input): simple__causeway_applib_DomainObjectList
causeway_applib_RoleMemento(object: simple__causeway_applib_RoleMemento__gqlv_input): simple__causeway_applib_RoleMemento
@@ -342,6 +351,8 @@ type rich__causeway_applib_DomainObjectList__elementTypeFqcn__gqlv_property {
}
type rich__causeway_applib_DomainObjectList__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -381,6 +392,8 @@ type rich__causeway_applib_RoleMemento__description__gqlv_property {
}
type rich__causeway_applib_RoleMemento__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -464,6 +477,8 @@ type rich__causeway_applib_UserMemento__avatarUrl__gqlv_property {
}
type rich__causeway_applib_UserMemento__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -632,6 +647,8 @@ type rich__causeway_conf_ConfigurationProperty {
}
type rich__causeway_conf_ConfigurationProperty__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -705,6 +722,8 @@ type rich__causeway_conf_ConfigurationViewmodel__environment__gqlv_collection_wi
}
type rich__causeway_conf_ConfigurationViewmodel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -787,6 +806,8 @@ type rich__causeway_feat_ApplicationFeatureViewModel {
}
type rich__causeway_feat_ApplicationFeatureViewModel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -886,6 +907,8 @@ type rich__causeway_feat_ApplicationNamespace__contents__gqlv_collection_window
}
type rich__causeway_feat_ApplicationNamespace__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -989,6 +1012,8 @@ type rich__causeway_feat_ApplicationTypeAction__actionSemantics__gqlv_property {
}
type rich__causeway_feat_ApplicationTypeAction__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1094,6 +1119,8 @@ type rich__causeway_feat_ApplicationTypeCollection__elementType__gqlv_property {
}
type rich__causeway_feat_ApplicationTypeCollection__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1162,6 +1189,8 @@ type rich__causeway_feat_ApplicationTypeMember {
}
type rich__causeway_feat_ApplicationTypeMember__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1249,6 +1278,8 @@ type rich__causeway_feat_ApplicationTypeProperty__derived__gqlv_property {
}
type rich__causeway_feat_ApplicationTypeProperty__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1395,6 +1426,8 @@ type rich__causeway_feat_ApplicationType__collections__gqlv_collection_window {
}
type rich__causeway_feat_ApplicationType__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1501,6 +1534,8 @@ type rich__causeway_metamodel_MetamodelInspectView__details__gqlv_property {
}
type rich__causeway_metamodel_MetamodelInspectView__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1565,6 +1600,8 @@ type rich__causeway_security_LoginRedirect {
}
type rich__causeway_security_LoginRedirect__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1630,6 +1667,8 @@ type rich__causeway_testing_fixtures_FixtureResult__fixtureScriptClassName__gqlv
}
type rich__causeway_testing_fixtures_FixtureResult__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1699,6 +1738,8 @@ type rich__java_lang_Record {
}
type rich__java_lang_Record__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1715,6 +1756,8 @@ type rich__java_util_concurrent_Callable {
}
type rich__java_util_concurrent_Callable__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1731,6 +1774,8 @@ type rich__java_util_function_Consumer {
}
type rich__java_util_function_Consumer__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1747,6 +1792,8 @@ type rich__java_util_function_Function {
}
type rich__java_util_function_Function__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1763,6 +1810,8 @@ type rich__java_util_stream_Stream {
}
type rich__java_util_stream_Stream__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1781,6 +1830,8 @@ type rich__org_apache_causeway_applib_graph_tree_MasterDetailTreeView {
}
type rich__org_apache_causeway_applib_graph_tree_MasterDetailTreeView__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -1823,6 +1874,8 @@ type rich__org_apache_causeway_testing_fixtures_applib_fixturescripts_FixtureScr
}
type rich__org_apache_causeway_testing_fixtures_applib_fixturescripts_FixtureScript__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -4108,6 +4161,8 @@ type rich__university_calc_EditableMementoViewModel__age__gqlv_property {
}
type rich__university_calc_EditableMementoViewModel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -5739,6 +5794,8 @@ type rich__university_dept_Department__disabledStaffMembers__gqlv_collection_win
}
type rich__university_dept_Department__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6185,6 +6242,8 @@ type rich__university_dept_DeptHead__department__gqlv_property {
}
type rich__university_dept_DeptHead__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6353,6 +6412,8 @@ type rich__university_dept_Person {
}
type rich__university_dept_Person__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6417,6 +6478,8 @@ type rich__university_dept_StaffMember__department__gqlv_property {
}
type rich__university_dept_StaffMember__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6639,6 +6702,8 @@ type simple__causeway_applib_DomainObjectList {
}
type simple__causeway_applib_DomainObjectList__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6657,6 +6722,8 @@ type simple__causeway_applib_RoleMemento {
}
type simple__causeway_applib_RoleMemento__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6685,6 +6752,8 @@ type simple__causeway_applib_UserMemento {
}
type simple__causeway_applib_UserMemento__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6713,6 +6782,8 @@ type simple__causeway_conf_ConfigurationProperty {
}
type simple__causeway_conf_ConfigurationProperty__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6735,6 +6806,8 @@ type simple__causeway_conf_ConfigurationViewmodel {
}
type simple__causeway_conf_ConfigurationViewmodel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6755,6 +6828,8 @@ type simple__causeway_feat_ApplicationFeatureViewModel {
}
type simple__causeway_feat_ApplicationFeatureViewModel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6777,6 +6852,8 @@ type simple__causeway_feat_ApplicationNamespace {
}
type simple__causeway_feat_ApplicationNamespace__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6814,6 +6891,8 @@ type simple__causeway_feat_ApplicationTypeAction {
}
type simple__causeway_feat_ApplicationTypeAction__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6836,6 +6915,8 @@ type simple__causeway_feat_ApplicationTypeCollection {
}
type simple__causeway_feat_ApplicationTypeCollection__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6856,6 +6937,8 @@ type simple__causeway_feat_ApplicationTypeMember {
}
type simple__causeway_feat_ApplicationTypeMember__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6880,6 +6963,8 @@ type simple__causeway_feat_ApplicationTypeProperty {
}
type simple__causeway_feat_ApplicationTypeProperty__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6891,6 +6976,8 @@ type simple__causeway_feat_ApplicationTypeProperty__gqlv_meta {
}
type simple__causeway_feat_ApplicationType__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6911,6 +6998,8 @@ type simple__causeway_metamodel_MetamodelInspectView {
}
type simple__causeway_metamodel_MetamodelInspectView__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6927,6 +7016,8 @@ type simple__causeway_security_LoginRedirect {
}
type simple__causeway_security_LoginRedirect__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6951,6 +7042,8 @@ type simple__causeway_testing_fixtures_FixtureResult {
}
type simple__causeway_testing_fixtures_FixtureResult__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6967,6 +7060,8 @@ type simple__java_lang_Record {
}
type simple__java_lang_Record__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6983,6 +7078,8 @@ type simple__java_util_concurrent_Callable {
}
type simple__java_util_concurrent_Callable__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -6999,6 +7096,8 @@ type simple__java_util_function_Consumer {
}
type simple__java_util_function_Consumer__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7015,6 +7114,8 @@ type simple__java_util_function_Function {
}
type simple__java_util_function_Function__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7031,6 +7132,8 @@ type simple__java_util_stream_Stream {
}
type simple__java_util_stream_Stream__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7048,6 +7151,8 @@ type simple__org_apache_causeway_applib_graph_tree_MasterDetailTreeView {
}
type simple__org_apache_causeway_applib_graph_tree_MasterDetailTreeView__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7066,6 +7171,8 @@ type simple__org_apache_causeway_testing_fixtures_applib_fixturescripts_FixtureS
}
type simple__org_apache_causeway_testing_fixtures_applib_fixturescripts_FixtureScript__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7203,6 +7310,8 @@ type simple__university_calc_EditableMementoViewModel {
}
type simple__university_calc_EditableMementoViewModel__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7306,6 +7415,8 @@ type simple__university_dept_Department {
}
type simple__university_dept_Department__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7339,6 +7450,8 @@ type simple__university_dept_DeptHead {
}
type simple__university_dept_DeptHead__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7371,6 +7484,8 @@ type simple__university_dept_Person {
}
type simple__university_dept_Person__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
@@ -7404,6 +7519,8 @@ type simple__university_dept_StaffMember {
}
type simple__university_dept_StaffMember__gqlv_meta {
+ "Navigable ancestors ordered from root to immediate parent."
+ breadcrumbs: [RichNavigableBreadcrumb!]
cssClass: String
grid: String
icon: String
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 02b447b9471..5c1d7c08a25 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -26,6 +26,7 @@ The runtime has no dependency on HTMX, React, Vue, Svelte, or a component-author
<cw-object-context
logical-type="university.dept.Department"
object-id="42">
+ <cw-breadcrumbs></cw-breadcrumbs>
<cw-object-header></cw-object-header>
<cw-property id="name"></cw-property>
<cw-property id="status"></cw-property>
@@ -49,7 +50,7 @@ The collection remains inactive until its load button is selected or its `active
== Public component contract
-The public vocabulary consists of `cw-graphql-client`, `cw-menubars`, `cw-menubar-primary`, `cw-menubar-secondary`, `cw-menubar-tertiary`, `cw-object-context`, `cw-object`, `cw-object-header`, `cw-property`, `cw-value`, `cw-object-link`, `cw-action`, `cw-interaction-controller`, `cw-field-editor`, `cw-reference-editor`, `cw-collection`, and `cw-collection-column`.
+The public vocabulary consists of `cw-graphql-client`, `cw-menubars`, `cw-menubar-primary`, `cw-menubar-secondary`, `cw-menubar-tertiary`, `cw-object-context`, `cw-object`, `cw-object-header`, `cw-breadcrumbs`, `cw-property`, `cw-value`, `cw-object-link`, `cw-action`, `cw-interaction-controller`, `cw-field-editor`, `cw-reference-editor`, `cw-collection`, and `cw-collection-column`.
`CausewayElementName`, `CausewayAttribute`, `CausewayHostClass`, and `CausewaySemanticEvent` export the stable names used by the implementation.
The `cw-` element namespace replaces the former `causeway-` element namespace without compatibility aliases.
@@ -126,6 +127,18 @@ Calling `refresh()` or `invalidate()` on the context controller executes the com
Disconnecting a component releases its requirement and shrinks future refreshes without causing a request by itself.
Nested object contexts answer descendant requests at the nearest provider boundary.
+== Navigable breadcrumbs
+
+`<cw-breadcrumbs>` registers one semantic metadata requirement with the nearest object context.
+It renders a `nav` landmark containing root-to-parent `<cw-object-link>` ancestors followed by the current object's title marked with `aria-current="page"`.
+The rich GraphQL `_meta.breadcrumbs` field derives ancestors from Causeway's standard navigable-parent facet, normally declared with `@PropertyLayout(navigable = Navigable.PARENT)`.
+Traversal returns at most 32 bookmarkable ancestors, detects cycles, and reports facet or depth failures locally without exposing application exception details.
+The current object is supplied by the existing `_meta` identity and title and is not duplicated in the ancestor list.
+
+Ancestor links publish the established bubbling and composed `causeway-navigation-request` event.
+The component does not construct URLs, choose a router, require HTMX, infer parents from arbitrary reference properties, or execute GraphQL independently of the shared context.
+Applications opt into presentation by placing the element in their composition; `<cw-object>` does not insert it automatically.
+
== Composite object and effective-grid layout
`<cw-object>` is a context-only high-level projection that generates the established header, property, action, and collection elements in light DOM.
diff --git a/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs b/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
new file mode 100644
index 00000000000..4c479984a19
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/breadcrumbs-element.mjs
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * 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 {CausewayContextConsumerElement} from './context-consumer-element.mjs';
+import {errorMessage, escapeHtml} from './rendering.mjs';
+
+export class CausewayBreadcrumbsElement extends CausewayContextConsumerElement {
+ createRequirement() {
+ return {kind: 'breadcrumbs'};
+ }
+
+ renderComponentState(state) {
+ if (['idle', 'schema-loading', 'object-loading'].includes(state.status)) {
+ this.innerHTML = `<nav class="causeway-breadcrumbs" aria-label="Breadcrumb" aria-busy="true">
+ <span role="status">Loading breadcrumbs…</span>
+</nav>`;
+ return;
+ }
+ if (['terminal-error', 'unsupported', 'partial-error'].includes(state.status)) {
+ this.innerHTML = `<nav class="causeway-breadcrumbs causeway-error" aria-label="Breadcrumb" aria-busy="false">
+ <span role="alert">${escapeHtml(errorMessage(state))}</span>
+</nav>`;
+ return;
+ }
+ const metadata = state.data ?? {};
+ const ancestors = Array.isArray(metadata.breadcrumbs)
+ ? metadata.breadcrumbs.filter(validBreadcrumb)
+ : [];
+ const currentTitle = metadata.title || `${metadata.logicalTypeName ?? ''}:${metadata.id ?? ''}`;
+ const ancestorItems = ancestors.map(ancestor => `<li class="causeway-breadcrumbs-item">
+ <cw-object-link logical-type="${escapeHtml(ancestor.logicalTypeName)}" object-id="${escapeHtml(ancestor.id)}" title="${escapeHtml(ancestor.title)}"></cw-object-link>
+ </li>`).join('\n ');
+ this.innerHTML = `<nav class="causeway-breadcrumbs" aria-label="Breadcrumb" aria-busy="false">
+ <ol class="causeway-breadcrumbs-list">
+ ${ancestorItems}${ancestorItems ? '\n ' : ''}<li class="causeway-breadcrumbs-item causeway-breadcrumbs-current" aria-current="page">${escapeHtml(currentTitle)}</li>
+ </ol>
+</nav>`;
+ }
+}
+
+function validBreadcrumb(entry) {
+ return entry
+ && typeof entry.logicalTypeName === 'string' && entry.logicalTypeName.length > 0
+ && typeof entry.id === 'string' && entry.id.length > 0
+ && typeof entry.title === 'string' && entry.title.length > 0;
+}
diff --git a/viewers/webcomponents/foundation/src/component-contracts.mjs b/viewers/webcomponents/foundation/src/component-contracts.mjs
index 91dd3faaaa8..65ea57e1593 100644
--- a/viewers/webcomponents/foundation/src/component-contracts.mjs
+++ b/viewers/webcomponents/foundation/src/component-contracts.mjs
@@ -25,6 +25,7 @@ export const CausewayElementName = Object.freeze({
OBJECT_CONTEXT: 'cw-object-context',
OBJECT: 'cw-object',
OBJECT_HEADER: 'cw-object-header',
+ BREADCRUMBS: 'cw-breadcrumbs',
PROPERTY: 'cw-property',
VALUE: 'cw-value',
OBJECT_LINK: 'cw-object-link',
@@ -45,6 +46,7 @@ export const CausewayElementName = Object.freeze({
export const CausewayHostClass = Object.freeze({
OBJECT: 'causeway-object',
OBJECT_HEADER: 'causeway-object-header',
+ BREADCRUMBS: 'causeway-breadcrumbs',
PROPERTY: 'causeway-property',
VALUE: 'causeway-value',
OBJECT_LINK: 'causeway-object-link',
diff --git a/viewers/webcomponents/foundation/src/component-styles.css b/viewers/webcomponents/foundation/src/component-styles.css
index 3592e20168b..d92e8f2909f 100644
--- a/viewers/webcomponents/foundation/src/component-styles.css
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -146,7 +146,12 @@ cw-collection-column[hidden] {
grid-column: 1 / -1;
}
}
+cw-breadcrumbs {
+ display: block;
+ min-inline-size: 0;
+}
.causeway-object-header,
+.causeway-breadcrumbs,
.causeway-property,
.causeway-action,
.causeway-collection {
@@ -368,6 +373,34 @@ cw-field-editor[data-widget-state="fallback"]::after {
transform: translateY(0);
visibility: visible;
}
+.causeway-breadcrumbs-list {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ list-style: none;
+ margin: 0;
+ min-inline-size: 0;
+ padding: 0;
+}
+.causeway-breadcrumbs-item {
+ align-items: center;
+ display: inline-flex;
+ max-inline-size: 100%;
+ min-inline-size: 0;
+ overflow-wrap: anywhere;
+}
+.causeway-breadcrumbs-item + .causeway-breadcrumbs-item::before {
+ content: "/";
+ margin-inline-end: 0.35rem;
+ opacity: 0.65;
+}
+.causeway-breadcrumbs-current {
+ font-weight: 600;
+}
+.causeway-breadcrumbs .causeway-object-link-identity {
+ display: none;
+}
.causeway-object-link {
align-items: baseline;
background: none;
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index 7b359aa86dd..88296dbb6b5 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -147,7 +147,12 @@ cw-collection-column[hidden] {
grid-column: 1 / -1;
}
}
+cw-breadcrumbs {
+ display: block;
+ min-inline-size: 0;
+}
.causeway-object-header,
+.causeway-breadcrumbs,
.causeway-property,
.causeway-action,
.causeway-collection {
@@ -369,6 +374,34 @@ cw-field-editor[data-widget-state="fallback"]::after {
transform: translateY(0);
visibility: visible;
}
+.causeway-breadcrumbs-list {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ list-style: none;
+ margin: 0;
+ min-inline-size: 0;
+ padding: 0;
+}
+.causeway-breadcrumbs-item {
+ align-items: center;
+ display: inline-flex;
+ max-inline-size: 100%;
+ min-inline-size: 0;
+ overflow-wrap: anywhere;
+}
+.causeway-breadcrumbs-item + .causeway-breadcrumbs-item::before {
+ content: "/";
+ margin-inline-end: 0.35rem;
+ opacity: 0.65;
+}
+.causeway-breadcrumbs-current {
+ font-weight: 600;
+}
+.causeway-breadcrumbs .causeway-object-link-identity {
+ display: none;
+}
.causeway-object-link {
align-items: baseline;
background: none;
diff --git a/viewers/webcomponents/foundation/src/graphql-client.mjs b/viewers/webcomponents/foundation/src/graphql-client.mjs
index 734219ff6e1..676398d6579 100644
--- a/viewers/webcomponents/foundation/src/graphql-client.mjs
+++ b/viewers/webcomponents/foundation/src/graphql-client.mjs
@@ -583,12 +583,14 @@ function referencedInteractionTypeNames(typeDescription, generatedTypeName, sche
&& ['OBJECT', 'INTERFACE', 'UNION'].includes(kind);
const objectMetadata = fieldName === '_meta' && kind === 'OBJECT';
const memberMetadata = fieldName === 'metadata' && kind === 'OBJECT';
+ const breadcrumbMetadata = fieldName === 'breadcrumbs' && kind === 'OBJECT';
if (typeName && (schemaNames.isReachableSupportType(generatedTypeName, typeName)
|| kind === 'ENUM' && typeName.startsWith('rich__')
|| choiceValue
|| objectValue
|| objectMetadata
- || memberMetadata)) {
+ || memberMetadata
+ || breadcrumbMetadata)) {
result.add(typeName);
}
};
diff --git a/viewers/webcomponents/foundation/src/index.mjs b/viewers/webcomponents/foundation/src/index.mjs
index fe1babb6809..ab77f981441 100644
--- a/viewers/webcomponents/foundation/src/index.mjs
+++ b/viewers/webcomponents/foundation/src/index.mjs
@@ -19,6 +19,7 @@
export * from './action-element.mjs';
export * from './action-widget.mjs';
+export * from './breadcrumbs-element.mjs';
export * from './collection-column-element.mjs';
export * from './collection-element.mjs';
export * from './collection-grid-projection.mjs';
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index 27443403c92..ba8c6579761 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -1065,10 +1065,10 @@ export class ObjectContextController extends EventTarget {
return;
}
const mappedStatus = mapRequirementStatus(this.state.status);
- const pathHead = requirement.kind === 'header' || requirement.kind === 'layout'
+ const pathHead = ['header', 'layout', 'breadcrumbs'].includes(requirement.kind)
? this.description?.metadata?.id
: requirement.member;
- const errors = this.snapshot?.errors.filter(error => error.path?.[0] === pathHead) ?? [];
+ const errors = this.snapshot?.errors.filter(error => errorMatchesRequirement(error, requirement, pathHead)) ?? [];
const data = this.snapshot && pathHead ? this.snapshot.data[pathHead] : null;
registration.listener(freezeRequirementState({
status: errors.length > 0 && mappedStatus === RequirementStatus.READY
@@ -1206,8 +1206,21 @@ function validateIdentity(identity, client) {
return null;
}
+function errorMatchesRequirement(error, requirement, pathHead) {
+ if (error.path?.[0] !== pathHead) {
+ return false;
+ }
+ if (requirement.kind === 'breadcrumbs') {
+ return error.path?.[1] === 'breadcrumbs';
+ }
+ if (requirement.kind === 'header' || requirement.kind === 'layout') {
+ return error.path?.[1] !== 'breadcrumbs';
+ }
+ return true;
+}
+
function normalizeRequirement(requirement) {
- if (requirement?.kind === 'header' || requirement?.kind === 'layout') {
+ if (['header', 'layout', 'breadcrumbs'].includes(requirement?.kind)) {
return Object.freeze({kind: requirement.kind});
}
if (['property', 'action', 'collection'].includes(requirement?.kind)
@@ -1219,11 +1232,31 @@ function normalizeRequirement(requirement) {
}
function translateRequirement(requirement, description) {
- if (requirement.kind === 'header' || requirement.kind === 'layout') {
+ if (['header', 'layout', 'breadcrumbs'].includes(requirement.kind)) {
const metadata = description.metadata;
if (!metadata) {
throw new Error(`Type '${description.generatedTypeName}' does not expose rich object metadata.`);
}
+ if (requirement.kind === 'breadcrumbs') {
+ const breadcrumbs = metadata.fields.get('breadcrumbs');
+ const breadcrumbType = description.types.get(namedType(breadcrumbs?.type));
+ const breadcrumbFields = fieldsByName(breadcrumbType);
+ const requiredFields = ['logicalTypeName', 'id', 'title'];
+ if (!breadcrumbs || requiredFields.some(field => !breadcrumbFields.has(field))) {
+ throw new Error(`Metadata type '${metadata.generatedTypeName}' lacks navigable breadcrumb fields.`);
+ }
+ const currentFields = ['id', 'logicalTypeName', 'title'];
+ if (currentFields.some(field => !metadata.fields.has(field))) {
+ throw new Error(`Metadata type '${metadata.generatedTypeName}' lacks object identity fields.`);
+ }
+ return {
+ descriptor: metadata,
+ selection: {[metadata.id]: {
+ ...Object.fromEntries(currentFields.map(field => [field, true])),
+ breadcrumbs: Object.fromEntries(requiredFields.map(field => [field, true]))
+ }}
+ };
+ }
const requestedFields = requirement.kind === 'header'
? ['id', 'logicalTypeName', 'title', 'version']
: ['grid', 'layout', 'cssClass'];
diff --git a/viewers/webcomponents/foundation/src/register.mjs b/viewers/webcomponents/foundation/src/register.mjs
index 6cecff11c4a..b783a0a17ef 100644
--- a/viewers/webcomponents/foundation/src/register.mjs
+++ b/viewers/webcomponents/foundation/src/register.mjs
@@ -18,6 +18,7 @@
*/
import {CausewayActionElement} from './action-element.mjs';
+import {CausewayBreadcrumbsElement} from './breadcrumbs-element.mjs';
import {CAUSEWAY_ACTION_CONTROL, CausewayActionControlElement} from './action-widget.mjs';
import {CausewayCollectionColumnElement} from './collection-column-element.mjs';
import {captureDeclarativeCollectionColumns, CausewayCollectionElement} from './collection-element.mjs';
@@ -46,6 +47,7 @@ const DEFINITIONS = Object.freeze([
[CausewayElementName.OBJECT_CONTEXT, CausewayObjectContextElement],
[CausewayElementName.OBJECT, CausewayObjectElement],
[CausewayElementName.OBJECT_HEADER, CausewayObjectHeaderElement],
+ [CausewayElementName.BREADCRUMBS, CausewayBreadcrumbsElement],
[CausewayElementName.PROPERTY, CausewayPropertyElement],
[CausewayElementName.VALUE, CausewayValueElement],
[CausewayElementName.OBJECT_LINK, CausewayObjectLinkElement],
diff --git a/viewers/webcomponents/foundation/test/components.test.mjs b/viewers/webcomponents/foundation/test/components.test.mjs
index eaa734e2897..7a4b0c7e980 100644
--- a/viewers/webcomponents/foundation/test/components.test.mjs
+++ b/viewers/webcomponents/foundation/test/components.test.mjs
@@ -25,6 +25,7 @@ const {document} = installDomShim();
const {
COMPONENT_STATE_EVENT,
CausewayActionElement,
+ CausewayBreadcrumbsElement,
CausewayCollectionColumnElement,
CausewayCollectionElement,
CausewayObjectContextElement,
@@ -36,6 +37,45 @@ const {
defineCausewayWebComponents();
+test('breadcrumbs render accessible ancestor links, escaped current state and local errors', () => {
+ const breadcrumbs = new CausewayBreadcrumbsElement();
+ breadcrumbs.renderComponentState(state({
+ data: {
+ id: 'visit-1',
+ logicalTypeName: 'petclinic.Visit',
+ title: 'Checkup <today>',
+ breadcrumbs: [
+ {logicalTypeName: 'petclinic.PetOwner', id: 'owner-1', title: 'Mary & family'},
+ {logicalTypeName: 'petclinic.Pet', id: 'pet-1', title: 'Basil'},
+ {logicalTypeName: '', id: 'invalid', title: 'Malformed'}
+ ]
+ }
+ }));
+
+ assert.match(breadcrumbs.innerHTML, /<nav class="causeway-breadcrumbs" aria-label="Breadcrumb"/);
+ assert.match(breadcrumbs.innerHTML, /<ol class="causeway-breadcrumbs-list">/);
+ assert.match(breadcrumbs.innerHTML, /logical-type="petclinic\.PetOwner"/);
+ assert.match(breadcrumbs.innerHTML, /object-id="owner-1"/);
+ assert.match(breadcrumbs.innerHTML, /Mary & family/);
+ assert.match(breadcrumbs.innerHTML, /Basil/);
+ assert.match(breadcrumbs.innerHTML, /aria-current="page">Checkup <today>/);
+ assert.doesNotMatch(breadcrumbs.innerHTML, /Malformed/);
+
+ breadcrumbs.renderComponentState(state({status: 'partial-error', errors: [{message: 'Breadcrumb cycle'}]}));
+ assert.match(breadcrumbs.innerHTML, /role="alert"/);
+ assert.match(breadcrumbs.innerHTML, /Breadcrumb cycle/);
+ assert.doesNotMatch(breadcrumbs.innerHTML, /object-id=/);
+});
+
+test('breadcrumbs render a current-only landmark for a root object', () => {
+ const breadcrumbs = new CausewayBreadcrumbsElement();
+ breadcrumbs.renderComponentState(state({
+ data: {id: 'owner-1', logicalTypeName: 'petclinic.PetOwner', title: 'Mary', breadcrumbs: []}
+ }));
+ assert.match(breadcrumbs.innerHTML, /aria-current="page">Mary/);
+ assert.doesNotMatch(breadcrumbs.innerHTML, /<cw-object-link/);
+});
+
test('property renders accessible ready, disabled, hidden and error states', () => {
const property = new CausewayPropertyElement();
property.setAttribute('id', 'name');
diff --git a/viewers/webcomponents/foundation/test/domain-components.test.mjs b/viewers/webcomponents/foundation/test/domain-components.test.mjs
index 19fa084c78a..8aee6386910 100644
--- a/viewers/webcomponents/foundation/test/domain-components.test.mjs
+++ b/viewers/webcomponents/foundation/test/domain-components.test.mjs
@@ -54,6 +54,7 @@ test('registers the complete compact custom-element vocabulary without old alias
'cw-object-context',
'cw-object',
'cw-object-header',
+ 'cw-breadcrumbs',
'cw-property',
'cw-value',
'cw-object-link',
diff --git a/viewers/webcomponents/foundation/test/element-prefix.test.mjs b/viewers/webcomponents/foundation/test/element-prefix.test.mjs
index 8c6841f7e64..451f480c25b 100644
--- a/viewers/webcomponents/foundation/test/element-prefix.test.mjs
+++ b/viewers/webcomponents/foundation/test/element-prefix.test.mjs
@@ -158,7 +158,7 @@ test('application markup does not expose raw Vaadin elements', async () => {
});
test('compact element names retain established non-element Causeway contracts', () => {
- assert.equal(Object.values(CausewayElementName).length, 16);
+ assert.equal(Object.values(CausewayElementName).length, 17);
assert.ok(Object.values(CausewayElementName).every(name => name.startsWith('cw-')));
assert.equal(CAUSEWAY_FIELD_EDITOR, 'cw-field-editor');
assert.equal(CausewayHostClass.PROPERTY, 'causeway-property');
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index ca882be8ce1..05b6645f0bc 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -20,6 +20,7 @@
const OBJECT_TYPE = 'rich__university_dept_Department';
const META_TYPE = `${OBJECT_TYPE}__gqlv_meta`;
const MEMBER_METADATA_TYPE = 'RichMemberMetadata';
+const BREADCRUMB_TYPE = 'RichNavigableBreadcrumb';
const NAME_TYPE = `${OBJECT_TYPE}__name__gqlv_property`;
const CODE_TYPE = `${OBJECT_TYPE}__code__gqlv_property`;
const STATUS_TYPE = `${OBJECT_TYPE}__status__gqlv_property`;
@@ -72,10 +73,16 @@ export function createRichSchemaTypes() {
field('logicalTypeName', null, scalar('String')),
field('version', null, scalar('String')),
field('title', null, scalar('String')),
+ field('breadcrumbs', null, list(named(BREADCRUMB_TYPE))),
field('grid', null, scalar('String')),
field('layout', null, scalar('String')),
field('cssClass', null, scalar('String'))
])],
+ [BREADCRUMB_TYPE, objectType(BREADCRUMB_TYPE, 'Navigable breadcrumb', [
+ field('logicalTypeName', null, scalar('String')),
+ field('id', null, scalar('ID')),
+ field('title', null, scalar('String'))
+ ])],
[MEMBER_METADATA_TYPE, objectType(MEMBER_METADATA_TYPE, 'Member metadata', [
field('friendlyName', null, scalar('String')),
field('description', null, scalar('String')),
@@ -139,7 +146,8 @@ export function createRichSchemaTypes() {
field('id', null, scalar('ID')),
field('logicalTypeName', null, scalar('String')),
field('version', null, scalar('String')),
- field('title', null, scalar('String'))
+ field('title', null, scalar('String')),
+ field('breadcrumbs', null, list(named(BREADCRUMB_TYPE)))
])],
[STAFF_NAME_TYPE, propertyType(STAFF_NAME_TYPE)],
[STAFF_CODE_TYPE, propertyType(STAFF_CODE_TYPE)]
@@ -247,7 +255,8 @@ export function departmentObjectData({
code = 'CLA',
codeHidden = false,
nameDisabled = null,
- version = '7'
+ version = '7',
+ breadcrumbs = []
} = {}) {
return {
_meta: {
@@ -255,6 +264,7 @@ export function departmentObjectData({
logicalTypeName: DEPARTMENT_LOGICAL_TYPE,
version,
title: 'Classics Department',
+ breadcrumbs,
grid: '/graphql/object/university.dept.Department:42/_meta/grid',
layout: null,
cssClass: 'department'
diff --git a/viewers/webcomponents/foundation/test/object-context.test.mjs b/viewers/webcomponents/foundation/test/object-context.test.mjs
index cc37d0b25c0..953f4fffa56 100644
--- a/viewers/webcomponents/foundation/test/object-context.test.mjs
+++ b/viewers/webcomponents/foundation/test/object-context.test.mjs
@@ -61,6 +61,62 @@ test('coalesces header and property requirements into one initial object read',
assert.equal(codeStates.at(-1).data.get, 'CLA');
});
+test('coalesces semantic breadcrumbs and isolates breadcrumb metadata errors', async () => {
+ const breadcrumbs = [
+ {logicalTypeName: 'university.University', id: 'u-1', title: 'Example University'},
+ {logicalTypeName: 'university.Faculty', id: 'f-1', title: 'Arts Faculty'}
+ ];
+ const executor = createRichSchemaFixtureExecutor({
+ readResponses: [graphQLObjectResponse({breadcrumbs})]
+ });
+ const context = createContext(executor);
+ let breadcrumbState;
+ context.registerRequirement({kind: 'breadcrumbs'}, state => { breadcrumbState = state; });
+ context.registerRequirement({kind: 'header'});
+ await waitFor(() => context.state.status === 'ready');
+
+ assert.equal(executor.readCalls.length, 1);
+ assert.match(executor.readCalls[0].document, /breadcrumbs\s*\{[\s\S]*logicalTypeName[\s\S]*id[\s\S]*title/);
+ assert.deepEqual(breadcrumbState.data.breadcrumbs, breadcrumbs);
+ assert.equal(breadcrumbState.data.title, 'Classics Department');
+
+ const failing = createRichSchemaFixtureExecutor({
+ readResponses: [{
+ ...graphQLObjectResponse({breadcrumbs: null}),
+ errors: [{
+ message: 'Navigable parent hierarchy contains a cycle.',
+ path: ['rich', DEPARTMENT_OBJECT_FIELD, '_meta', 'breadcrumbs'],
+ extensions: {classification: 'DataFetchingException'}
+ }]
+ }]
+ });
+ const failingContext = createContext(failing);
+ let failingBreadcrumbs;
+ let successfulHeader;
+ failingContext.registerRequirement({kind: 'breadcrumbs'}, state => { failingBreadcrumbs = state; });
+ failingContext.registerRequirement({kind: 'header'}, state => { successfulHeader = state; });
+ await waitFor(() => failingContext.state.status === 'partial-error');
+ assert.equal(failingBreadcrumbs.status, 'partial-error');
+ assert.equal(successfulHeader.status, 'ready');
+ assert.equal(successfulHeader.data.title, 'Classics Department');
+});
+
+test('reports breadcrumbs unsupported when metadata lacks the shared entry field', async () => {
+ const types = createRichSchemaTypes();
+ for (const [name, type] of types) {
+ if (name.endsWith('__gqlv_meta')) {
+ types.set(name, {...type, fields: type.fields.filter(field => field.name !== 'breadcrumbs')});
+ }
+ }
+ const executor = createRichSchemaFixtureExecutor({types});
+ const context = createContext(executor);
+ let breadcrumbState;
+ context.registerRequirement({kind: 'breadcrumbs'}, state => { breadcrumbState = state; });
+ await waitFor(() => breadcrumbState?.status === 'unsupported');
+ assert.match(breadcrumbState.errors[0].message, /lacks navigable breadcrumb fields/);
+ assert.equal(executor.readCalls.length, 0);
+});
+
test('property reads remain compatible when member metadata is unavailable', async () => {
const types = createRichSchemaTypes();
for (const [name, type] of types) {
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
index 6868c71e507..39f83eee238 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -101,7 +101,8 @@ petclinic.Visit.html
Each exact logical-type filename is discovered at startup and its literal UTF-8 content is placed beneath the viewer-provided route context.
The sample opts into `causeway.viewer.webcomponents.htmx.resource-page-mode=reload`, while the viewer's production-oriented default remains `cached`.
-The files compose object headers, ordinary application regions, properties, actions, collections, and declared columns while GraphQL remains authoritative for identity, values, visibility, usability, validation, invocation, and results.
+The files compose navigable breadcrumbs, object headers, ordinary application regions, properties, actions, collections, and declared columns while GraphQL remains authoritative for identity, hierarchy, values, visibility, usability, validation, invocation, and results.
+`Pet.petOwner` and `Visit.pet` use `@PropertyLayout(navigable = Navigable.PARENT)`, producing the deterministic Mary Smith → Basil → visit hierarchy without application-specific breadcrumb rendering or route code.
`petclinic.PetOwner.html` demonstrates natural member association by nesting `updateName` beneath `name`, `addPet` and `removePet` beneath `pets`, and `bookVisit` beneath `visits`.
The retained `PetOwner.layout.xml` expresses the equivalent generated-grid associations, while the direct HTML needs no adjacent Petclinic-only association wrapper.
They use the configured `static/css/application.css`, public Causeway hooks, and design variables without inline styles or raw Vaadin elements.
@@ -122,7 +123,8 @@ An empty, unreadable, oversized, malformed UTF-8, or NUL-containing edit fails t
The primary menus cover Pet Owner creation, search, list, count, and upcoming visits.
HTML object pages cover editable properties, multiline notes, enum values, references, responsive cards, adjacent action groups, related Pets, contributed Visits, and contributed booking.
-The sample's current integration tests cover fixture identities, all four packaged pages, private-resource isolation, retained menu and grid resources, rich service and object operations, shell and fragment responses, exact resource precedence, generic fallback contracts, browser assets, and Wicket availability.
+The sample's current integration tests cover fixture identities, all four packaged pages, private-resource isolation, retained menu and grid resources, rich service and object operations including navigable ancestor metadata, shell and fragment responses, exact resource precedence, generic fallback contracts, browser assets, and Wicket availability.
+Browser acceptance verifies owner, pet, and visit breadcrumb order, current state, canonical ancestor navigation, responsive presentation, and ordinary clean-diagnostic gates.
== Wicket-relative presentation
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
index f7dadd72739..03090a25324 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
@@ -38,6 +38,7 @@
import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.applib.annotation.DomainObjectLayout;
import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Navigable;
import org.apache.causeway.applib.annotation.ObjectSupport;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.PropertyLayout;
@@ -67,7 +68,7 @@ public int compare(final Pet left, final Pet right) {
@ManyToOne(optional = false)
@JoinColumn(name = "owner_id", nullable = false)
- @PropertyLayout(fieldSetId = "identity", sequence = "1")
+ @PropertyLayout(fieldSetId = "identity", sequence = "1", navigable = Navigable.PARENT)
private PetOwner petOwner;
@Column(nullable = false, length = 40)
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
index 34c6ed487d1..4eb48fe98eb 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
@@ -34,6 +34,7 @@
import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.applib.annotation.DomainObjectLayout;
import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Navigable;
import org.apache.causeway.applib.annotation.ObjectSupport;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.PropertyLayout;
@@ -56,7 +57,7 @@ public class Visit implements Comparable<Visit> {
@ManyToOne(optional = false)
@JoinColumn(name = "pet_id", nullable = false)
- @PropertyLayout(fieldSetId = "identity", sequence = "1")
+ @PropertyLayout(fieldSetId = "identity", sequence = "1", navigable = Navigable.PARENT)
private Pet pet;
@Column(nullable = false)
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Pet.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Pet.html
index 99a928c0b1b..5b5aeea8067 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Pet.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Pet.html
@@ -14,6 +14,7 @@
limitations under the License.
-->
<article class="petclinic-page petclinic-pet-page" data-testid="petclinic-pet-page">
+ <cw-breadcrumbs data-testid="petclinic-breadcrumbs"></cw-breadcrumbs>
<cw-object-header></cw-object-header>
<div class="petclinic-object-grid">
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 ba29e7a86bf..cc516415d6c 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
@@ -14,6 +14,7 @@
limitations under the License.
-->
<article class="petclinic-page petclinic-owner-page" data-testid="petclinic-owner-page">
+ <cw-breadcrumbs data-testid="petclinic-breadcrumbs"></cw-breadcrumbs>
<cw-object-header></cw-object-header>
<div class="petclinic-page-toolbar" aria-label="Owner actions">
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Visit.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Visit.html
index 994af6e9635..dbbde9e3796 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Visit.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.Visit.html
@@ -14,6 +14,7 @@
limitations under the License.
-->
<article class="petclinic-page petclinic-visit-page" data-testid="petclinic-visit-page">
+ <cw-breadcrumbs data-testid="petclinic-breadcrumbs"></cw-breadcrumbs>
<cw-object-header></cw-object-header>
<div class="petclinic-object-grid">
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 b916780e905..33463d565da 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
@@ -209,6 +209,9 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertThat(page.locator(ROUTE_PAGE).getAttribute("data-page-kind")).isEqualTo("custom");
assertThat(page.locator(ROUTE_PAGE).getAttribute("data-page-source")).isEqualTo("resource");
assertThat(page.locator("[data-testid='petclinic-owner-page']").isVisible()).isTrue();
+ waitForBreadcrumbs(0);
+ assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] [aria-current='page']").textContent())
+ .isEqualTo("Mary Smith (Mary)");
assertThat(page.locator(".petclinic-object-grid").evaluate("""
element => {
const details = element.querySelector('.petclinic-object-details').getBoundingClientRect();
@@ -263,12 +266,26 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
waitForRoute("petclinic.Pet", "s_pet-basil");
assertThat(page.locator("[data-testid='petclinic-pet-page']").isVisible()).isTrue();
waitForObjectTitle("Basil · dog");
+ waitForBreadcrumbs(1);
+ assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link").getAttribute("title"))
+ .isEqualTo("Mary Smith (Mary)");
+ assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] [aria-current='page']").textContent())
+ .isEqualTo("Basil · dog");
waitForMenus();
page.navigate(url("/htmx/object/petclinic.Visit/s_visit-basil-checkup"),
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
waitForRoute("petclinic.Visit", "s_visit-basil-checkup");
assertThat(page.locator("[data-testid='petclinic-visit-page']").isVisible()).isTrue();
+ waitForBreadcrumbs(2);
+ assertThat(page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link")
+ .evaluateAll("elements => elements.map(element => element.getAttribute('title')).join(',')"))
+ .isEqualTo("Mary Smith (Mary),Basil · dog");
+ page.locator("[data-testid='petclinic-breadcrumbs'] cw-object-link[title='Basil · dog'] button").click();
+ waitForRoute("petclinic.Pet", "s_pet-basil");
+ page.goBack();
+ waitForRoute("petclinic.Visit", "s_visit-basil-checkup");
+ waitForBreadcrumbs(2);
waitForMenus();
page.setViewportSize(390, 844);
@@ -944,6 +961,12 @@ private void revealContainingTab(final Locator host, final Locator control) {
}
}
+ private void waitForBreadcrumbs(final int ancestorCount) {
+ page.waitForFunction(
+ "count => document.querySelector(\"[data-testid='petclinic-breadcrumbs'] [aria-current='page']\") && document.querySelectorAll(\"[data-testid='petclinic-breadcrumbs'] cw-object-link\").length === count",
+ ancestorCount);
+ }
+
private void waitForCollectionRows(final String member, final int count) {
try {
page.waitForFunction("args => document.querySelector(`cw-collection[id='${args.member}']`)?.collectionState?.rows?.length === args.count",
@@ -1013,7 +1036,17 @@ private void fillEditor(final Locator editor, final String value) {
}
private void assertFocused(final String selector) {
- page.waitForFunction("selector => [...document.querySelectorAll(selector)].some(element => element === document.activeElement || element.contains(document.activeElement) || element.shadowRoot?.activeElement)", selector);
+ try {
+ page.waitForFunction("selector => [...document.querySelectorAll(selector)].some(element => element === document.activeElement || element.contains(document.activeElement) || element.shadowRoot?.activeElement)", selector);
+ } catch (RuntimeException ex) {
+ final var activeElement = page.evaluate("""
+ () => {
+ const active = document.activeElement;
+ return active ? `${active.localName}${active.id ? `#${active.id}` : ''}${active.className ? `.${String(active.className).replaceAll(' ', '.')}` : ''}` : '<none>';
+ }
+ """);
+ throw new AssertionError("Expected focus within " + selector + " but active element was " + activeElement, ex);
+ }
assertThat(page.locator(selector).count()).isGreaterThan(0);
}
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 0302824a854..bc83c37d7ef 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
@@ -202,6 +202,9 @@ void packagesHtmlPagesAndRetainsLayoutFallbackResources() throws Exception {
.contains("<cw-object-header")
.contains("<cw-")
.doesNotContain("<script", " style=", " onclick=", "<vaadin-");
+ if (!name.equals("petclinic.HomePage.html")) {
+ assertThat(html).contains("<cw-breadcrumbs data-testid=\"petclinic-breadcrumbs\"");
+ }
}
final String ownerHtml;
try (var input = loader.getResource(
@@ -330,6 +333,45 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/get").size()).isEqualTo(2);
}
+ @Test
+ void exposesNavigableBreadcrumbHierarchyThroughRichGraphQL() throws Exception {
+ final var response = graphQL("""
+ query PetClinicBreadcrumbs {
+ __type(name: "RichNavigableBreadcrumb") {
+ fields { name type { kind name ofType { kind name } } }
+ }
+ rich {
+ petclinic_PetOwner(object: {id: "s_owner-mary"}) {
+ _meta { breadcrumbs { logicalTypeName id title } }
+ }
+ petclinic_Pet(object: {id: "s_pet-basil"}) {
+ _meta { breadcrumbs { logicalTypeName id title } }
+ }
+ petclinic_Visit(object: {id: "s_visit-basil-checkup"}) {
+ _meta { breadcrumbs { logicalTypeName id title } }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(response);
+ assertThat(response.at("/data/__type/fields"))
+ .extracting(node -> node.path("name").asText())
+ .containsExactlyInAnyOrder("logicalTypeName", "id", "title");
+ assertThat(response.at("/data/rich/petclinic_PetOwner/_meta/breadcrumbs")).isEmpty();
+ assertThat(response.at("/data/rich/petclinic_Pet/_meta/breadcrumbs"))
+ .extracting(node -> node.path("logicalTypeName").asText())
+ .containsExactly("petclinic.PetOwner");
+ assertThat(response.at("/data/rich/petclinic_Pet/_meta/breadcrumbs/0/id").asText())
+ .isEqualTo("s_owner-mary");
+ assertThat(response.at("/data/rich/petclinic_Visit/_meta/breadcrumbs"))
+ .extracting(node -> node.path("logicalTypeName").asText())
+ .containsExactly("petclinic.PetOwner", "petclinic.Pet");
+ assertThat(response.at("/data/rich/petclinic_Visit/_meta/breadcrumbs/0/title").asText())
+ .isEqualTo("Mary Smith (Mary)");
+ assertThat(response.at("/data/rich/petclinic_Visit/_meta/breadcrumbs/1/id").asText())
+ .isEqualTo("s_pet-basil");
+ }
+
private void assertResourcePage(
final String path,
final String logicalTypeName,
(causeway) 07/07: docs(openspec): archive single field boundaries
Posted by da...@apache.org.
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 3b422efe7ceb86feb0aebd28e317cbcd81e402c3
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:55:58 2026 +0200
docs(openspec): archive single field boundaries
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../vaadin-semantic-presentation-adapters/spec.md | 0
.../tasks.md | 0
.../vaadin-semantic-presentation-adapters/spec.md | 23 ++++++++++++++++++++++
6 files changed, 23 insertions(+)
diff --git a/openspec/changes/remove-field-double-borders/.openspec.yaml b/openspec/changes/archive/2026-08-29-remove-field-double-borders/.openspec.yaml
similarity index 100%
rename from openspec/changes/remove-field-double-borders/.openspec.yaml
rename to openspec/changes/archive/2026-08-29-remove-field-double-borders/.openspec.yaml
diff --git a/openspec/changes/remove-field-double-borders/design.md b/openspec/changes/archive/2026-08-29-remove-field-double-borders/design.md
similarity index 100%
rename from openspec/changes/remove-field-double-borders/design.md
rename to openspec/changes/archive/2026-08-29-remove-field-double-borders/design.md
diff --git a/openspec/changes/remove-field-double-borders/proposal.md b/openspec/changes/archive/2026-08-29-remove-field-double-borders/proposal.md
similarity index 100%
rename from openspec/changes/remove-field-double-borders/proposal.md
rename to openspec/changes/archive/2026-08-29-remove-field-double-borders/proposal.md
diff --git a/openspec/changes/remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md b/openspec/changes/archive/2026-08-29-remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
similarity index 100%
rename from openspec/changes/remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
rename to openspec/changes/archive/2026-08-29-remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
diff --git a/openspec/changes/remove-field-double-borders/tasks.md b/openspec/changes/archive/2026-08-29-remove-field-double-borders/tasks.md
similarity index 100%
rename from openspec/changes/remove-field-double-borders/tasks.md
rename to openspec/changes/archive/2026-08-29-remove-field-double-borders/tasks.md
diff --git a/openspec/specs/vaadin-semantic-presentation-adapters/spec.md b/openspec/specs/vaadin-semantic-presentation-adapters/spec.md
index 062e510ea30..4ada9aefed9 100644
--- a/openspec/specs/vaadin-semantic-presentation-adapters/spec.md
+++ b/openspec/specs/vaadin-semantic-presentation-adapters/spec.md
@@ -175,3 +175,26 @@ Every adopted read-only field and action button SHALL pass keyboard, accessible-
- **THEN** GraphQL outcomes, semantic events, route identities, values, validation, redaction, and reviewed classifications match
- **AND** no Vaadin asset or style hash is present
+### Requirement: Single toolkit field boundary
+A toolkit-backed read-only field SHALL present one coherent toolkit-owned control boundary and MUST NOT inherit native-control border, padding, sizing, focus, or resize chrome on its slotted internal form element.
+The internal control MUST retain genuine read-only semantics, accessible relationships, responsive sizing, theme variables, visible focus, and forced-colors behavior.
+
+#### Scenario: Read-only text field is rendered
+- **WHEN** an eligible string property uses the Vaadin read-only field adapter
+- **THEN** the field presents one visible bordered rectangle around its value
+- **AND** no separately bordered native input appears inside that rectangle
+
+#### Scenario: Read-only multiline, choice, numeric, or temporal field is rendered
+- **WHEN** another qualified field family creates a native form element in its reviewed internal input slot
+- **THEN** global native-control rules do not add a duplicate nested boundary or padding
+- **AND** the toolkit retains ownership of that field's read-only presentation
+
+#### Scenario: Native control is rendered outside a toolkit field
+- **WHEN** a Causeway fallback, editor, prompt, shell, or application control is not a toolkit-owned slotted internal input
+- **THEN** the established native theme border, sizing, padding, and focus rules continue to apply
+
+#### Scenario: Field receives keyboard focus or forced-colors presentation
+- **WHEN** the toolkit-backed field exposes its supported focus or forced-colors state
+- **THEN** its toolkit-owned visible indicator remains present and distinguishable
+- **AND** the duplicate native-control chrome does not reappear
+
(causeway) 01/07: docs(openspec): propose breadcrumbs web component
Posted by da...@apache.org.
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 286f5d0d6f31b558aeca07d58a9914ee3af72d0b
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 09:59:25 2026 +0200
docs(openspec): propose breadcrumbs web component
---
.../add-breadcrumbs-web-component/.openspec.yaml | 2 +
.../add-breadcrumbs-web-component/design.md | 99 ++++++++++++++++++++++
.../add-breadcrumbs-web-component/proposal.md | 31 +++++++
.../specs/domain-web-components/spec.md | 57 +++++++++++++
.../generic-htmx-web-component-viewer/spec.md | 35 ++++++++
.../specs/graphql-web-component-context/spec.md | 40 +++++++++
.../rich-graphql-navigable-breadcrumbs/spec.md | 64 ++++++++++++++
.../changes/add-breadcrumbs-web-component/tasks.md | 36 ++++++++
8 files changed, 364 insertions(+)
diff --git a/openspec/changes/add-breadcrumbs-web-component/.openspec.yaml b/openspec/changes/add-breadcrumbs-web-component/.openspec.yaml
new file mode 100644
index 00000000000..50adc91034f
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-29
diff --git a/openspec/changes/add-breadcrumbs-web-component/design.md b/openspec/changes/add-breadcrumbs-web-component/design.md
new file mode 100644
index 00000000000..5af7296504d
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/design.md
@@ -0,0 +1,99 @@
+## Context
+
+Causeway's metamodel already resolves one navigable parent for an object through `ObjectSpecification.getNavigableParent(Object)`, backed by `NavigableParentFacet` and normally declared with `@PropertyLayout(navigable = Navigable.PARENT)`.
+The rich GraphQL `_meta` object currently exposes the current object's bookmark identity and title, but no navigable hierarchy.
+The browser object context coordinates metadata and member selections for semantic consumers such as `<cw-object-header>`, while `<cw-object-link>` already owns framework-neutral navigation events.
+
+The feature crosses metamodel-backed GraphQL resolution, targeted schema discovery, coordinated browser projection, component rendering, HTMX navigation, and the Petclinic sample.
+It must remain bounded, authorization-safe, cycle-safe, framework-neutral, and compatible with clients that do not select the new field.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Project the current object's navigable ancestors from the established Causeway facet.
+- Give `<cw-breadcrumbs>` one semantic context requirement and no knowledge of generated GraphQL names.
+- Render accessible root-to-current breadcrumbs using existing semantic object links and navigation events.
+- Demonstrate owner → pet → visit hierarchy in Petclinic.
+- Keep schema discovery, runtime traversal, response size, and diagnostics bounded.
+
+**Non-Goals:**
+
+- Infer hierarchy from arbitrary references, JPA relationships, collections, URLs, or browser history.
+- Persist, cache globally, or mutate breadcrumb relationships.
+- Add an application-specific breadcrumb provider or a second navigation event contract.
+- Make breadcrumbs part of every object header or generic page automatically.
+- Render dropdown ancestors, sibling menus, icons, or an unbounded tree.
+
+## Decisions
+
+### Add a bounded ancestor list beneath rich object metadata
+
+Each generated rich `_meta` type will expose a nullable `breadcrumbs` field containing root-to-immediate-parent entries with non-null `logicalTypeName`, `id`, and `title` fields.
+The current object is not repeated in the list because its existing metadata already supplies identity and title and `<cw-breadcrumbs>` needs to mark it as the non-link current item.
+The shared breadcrumb-entry GraphQL type will be registered once and reused across generated object metadata types.
+
+A metadata list is preferred to recursively returning arbitrary generated rich object types because a navigable parent can have a different logical type at every level and the component needs only navigation identity and title.
+It also permits one bounded object read instead of sequential browser round trips for each ancestor.
+Adding a flag to member metadata is rejected because the runtime hierarchy belongs to the object and the navigable-parent facet can be imperative.
+
+### Traverse on the server through the existing facet
+
+`CommonMetaFetcher` will start from the current managed object, call `objSpec().getNavigableParent(pojo)`, adapt each returned parent, obtain its bookmark and title, and repeat from that parent.
+Traversal will stop at a null parent or an unavailable bookmark.
+It will preserve ancestor order by collecting upward and reversing the result before returning it.
+
+The traversal will accept at most 32 ancestors and track bookmark identities already visited, including the current object.
+A cycle, facet exception, or depth overflow will fail only the nullable `breadcrumbs` field with a bounded safe GraphQL error rather than returning a misleading partial chain or invalidating sibling metadata fields.
+No Java class names, arbitrary object strings, or exception details will enter the client diagnostic.
+
+The bound is fixed rather than configurable because it is a protocol safety limit, not an application presentation preference.
+Client-side traversal is rejected because it multiplies requests and asks the browser to reproduce server metamodel semantics.
+
+### Treat breadcrumbs as one semantic context requirement
+
+`ObjectContextController` will accept `{kind: 'breadcrumbs'}` and translate it to the current metadata identity, title, and nested breadcrumb entry selection.
+Targeted introspection will include the breadcrumb-entry object type only when reached from metadata.
+The requirement will participate in the existing initial coalescing, delta loading, refresh, cancellation, immutable snapshots, and metadata-path partial-error behavior.
+
+A standalone component issuing GraphQL directly is rejected because it would bypass context coordination and duplicate generated-name handling.
+Piggybacking every header read is rejected because pages that do not contain breadcrumbs should incur no breadcrumb traversal or response bytes.
+
+### Compose existing semantic links
+
+`<cw-breadcrumbs>` will extend `CausewayContextConsumerElement`, register the breadcrumbs requirement, and render a `<nav aria-label="Breadcrumb">` containing an ordered list.
+Each ancestor will be represented by `<cw-object-link>` using returned identity and title, while the current object will be escaped text marked with `aria-current="page"`.
+The component therefore reuses the established bubbling and composed `causeway-navigation-request` event and remains independent of HTMX and URL formats.
+
+The component will render local loading, unsupported, partial-error, and terminal-error states consistently with other consumers.
+With no ancestors it will render only the current item rather than disappear, so assistive technology retains a stable location landmark.
+Malformed entries will be omitted defensively, but the server contract and tests remain authoritative.
+
+### Use opt-in page composition
+
+The component will be registered, exported, documented, and styled as part of the public foundation vocabulary, but it will not be inserted automatically by `<cw-object>` or the HTMX viewer.
+Applications decide where breadcrumbs belong by writing `<cw-breadcrumbs></cw-breadcrumbs>` in custom HTML or another host composition.
+
+Petclinic will mark `Pet.petOwner` and `Visit.pet` as `Navigable.PARENT` and include the component on its custom object pages.
+The owner has no parent, a pet has its owner, and a visit has its pet and owner, providing deterministic zero-, one-, and two-ancestor coverage.
+HTMX's existing semantic navigation bridge will turn ancestor link events into canonical routes without component-specific integration.
+
+## Risks / Trade-offs
+
+- [Risk] An imperative parent method can be expensive or throw. → Evaluate it only when breadcrumbs are selected, bound traversal to 32, and isolate failures to the nullable metadata field.
+- [Risk] Cyclic parent relationships could loop or produce misleading navigation. → Track bookmarks, reject cycles with a bounded diagnostic, and never return a truncated cycle.
+- [Risk] A parent cannot be bookmarked. → Stop before that parent because no stable semantic navigation target exists.
+- [Risk] Ancestor titles can vary by locale or state. → Resolve titles per request through the current managed objects and do not globally cache chains.
+- [Risk] Adding a nested metadata type expands targeted introspection. → Discover exactly one shared support type only for consumers that register breadcrumbs.
+- [Risk] Petclinic hierarchy annotations can affect other Causeway viewers. → Use the standard existing `Navigable.PARENT` semantic intentionally and cover Wicket/HTMX startup and navigation regressions.
+- [Risk] Long titles can distort responsive layout. → Use wrapping and overflow-safe CSS while preserving full escaped accessible text.
+
+## Migration Plan
+
+Add the optional GraphQL field, then add browser discovery and context support, then register the component, and finally opt Petclinic pages and domain properties into the feature.
+Existing GraphQL documents, components, and pages remain unchanged unless they select or compose breadcrumbs.
+Rollback removes `<cw-breadcrumbs>` from application pages and the Petclinic navigable annotations; the additive GraphQL field can remain without affecting clients.
+
+## Open Questions
+
+None.
diff --git a/openspec/changes/add-breadcrumbs-web-component/proposal.md b/openspec/changes/add-breadcrumbs-web-component/proposal.md
new file mode 100644
index 00000000000..ffc8ade6ca2
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/proposal.md
@@ -0,0 +1,31 @@
+## Why
+
+Causeway already models an object's navigable parent through `@PropertyLayout(navigable = Navigable.PARENT)`, but the rich GraphQL and web-component stack does not expose that hierarchy to custom pages.
+Applications therefore cannot provide consistent, metadata-driven breadcrumbs without duplicating parent relationships and navigation logic in application-specific code.
+
+## What Changes
+
+- Add a bounded rich GraphQL metadata projection for the current object's navigable ancestor chain, derived from the existing navigable-parent facet.
+- Add a semantic breadcrumbs read requirement to the shared object context so descendant components do not construct GraphQL selections.
+- Introduce the framework-neutral `<cw-breadcrumbs>` custom element with accessible root-to-current navigation and standard semantic navigation events.
+- Bound ancestor traversal, detect cycles, omit unavailable identities safely, and preserve GraphQL authorization and partial-error behavior.
+- Add stable component names, classes, styling, documentation, fixtures, and automated tests.
+- Mark Petclinic's `Pet.petOwner` and `Visit.pet` properties as navigable parents and compose `<cw-breadcrumbs>` into its HTML-authored pages.
+- Extend Petclinic integration and Playwright coverage for owner → pet → visit breadcrumb hierarchy and navigation.
+
+## Capabilities
+
+### New Capabilities
+
+- `rich-graphql-navigable-breadcrumbs`: Expose a bounded root-to-parent identity and title chain from Causeway's navigable-parent facet through rich object metadata.
+
+### Modified Capabilities
+
+- `graphql-web-component-context`: Add a semantic breadcrumbs requirement to coordinated rich object reads.
+- `domain-web-components`: Add the accessible framework-neutral `<cw-breadcrumbs>` component and its semantic navigation behavior.
+- `generic-htmx-web-component-viewer`: Demonstrate metadata-driven breadcrumbs and canonical navigation in the Petclinic HTML-authored pages.
+
+## Impact
+
+The change affects rich GraphQL common object metadata and tests, web-component schema discovery, object-context projection and component registration, component styles and documentation, package fixtures and browser tests, Petclinic domain annotations and HTML pages, HTMX route navigation coverage, and the generated GraphQL contract.
+No new third-party dependency, route format, application-specific breadcrumb API, or direct browser access to metamodel services is introduced.
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/domain-web-components/spec.md b/openspec/changes/add-breadcrumbs-web-component/specs/domain-web-components/spec.md
new file mode 100644
index 00000000000..49cfbc1bb5d
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/specs/domain-web-components/spec.md
@@ -0,0 +1,57 @@
+## ADDED Requirements
+
+### Requirement: Semantic object breadcrumbs
+The component library SHALL provide a framework-neutral `<cw-breadcrumbs>` custom element that consumes the nearest object context's semantic breadcrumbs requirement and renders the navigable ancestor chain followed by the current object.
+
+#### Scenario: Object has multiple ancestors
+- **WHEN** `<cw-breadcrumbs>` receives root-to-parent entries and current-object metadata
+- **THEN** it renders each ancestor as a semantic `<cw-object-link>` in the supplied order
+- **AND** renders the escaped current title as the final non-link item marked `aria-current="page"`
+
+#### Scenario: Object has no ancestors
+- **WHEN** the breadcrumb list is empty and current-object metadata is available
+- **THEN** the component renders a stable breadcrumb landmark containing only the current item
+
+#### Scenario: Ancestor entry is malformed
+- **WHEN** an entry lacks a logical type name, identifier, or title
+- **THEN** the component omits that entry defensively
+- **AND** does not construct a partial navigation target
+
+### Requirement: Accessible breadcrumb presentation
+`<cw-breadcrumbs>` SHALL render keyboard-operable, escaped, responsive light-DOM markup with a `nav` landmark labelled `Breadcrumb`, an ordered list, documented semantic classes, and ordinary link focus behavior.
+
+#### Scenario: Assistive technology inspects breadcrumbs
+- **WHEN** breadcrumb state is ready
+- **THEN** the navigation landmark and list communicate hierarchy order
+- **AND** exactly one final current item exposes `aria-current="page"`
+
+#### Scenario: Breadcrumb title is long or contains markup-like text
+- **WHEN** an ancestor or current title contains long text or HTML-significant characters
+- **THEN** the title is escaped as text and wraps without causing horizontal page overflow
+
+### Requirement: Existing semantic navigation ownership
+Ancestor activation SHALL use the existing bubbling and composed `causeway-navigation-request` contract produced by `<cw-object-link>` and SHALL NOT impose a URL, router, HTMX, or application callback API.
+
+#### Scenario: User activates an ancestor
+- **WHEN** the user follows an ancestor breadcrumb by pointer or keyboard
+- **THEN** one semantic navigation event identifies that ancestor's logical type, id, and title
+- **AND** the host remains responsible for route handling
+
+### Requirement: Local breadcrumb states
+`<cw-breadcrumbs>` SHALL render loading, unsupported, partial-error, and terminal-error states locally without suppressing successful sibling components.
+
+#### Scenario: Breadcrumb traversal is loading
+- **WHEN** the semantic requirement is awaiting schema or object data
+- **THEN** the component exposes a bounded busy status within its own landmark
+
+#### Scenario: Breadcrumb traversal fails
+- **WHEN** the requirement reports unsupported, partial-error, or terminal-error state
+- **THEN** the component renders a bounded accessible local diagnostic
+- **AND** does not render stale ancestor links
+
+### Requirement: Public breadcrumb component contract
+The component registry, ECMAScript exports, semantic element-name constants, stylesheet surfaces, usage documentation, fixtures, and automation hooks SHALL include `<cw-breadcrumbs>` without changing existing component contracts.
+
+#### Scenario: Application uses plain HTML
+- **WHEN** an application imports the standard foundation entry module and writes `<cw-breadcrumbs>` beneath an object context
+- **THEN** the element is registered and operates without a frontend framework or direct GraphQL document construction
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md b/openspec/changes/add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
new file mode 100644
index 00000000000..e11309973af
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/specs/generic-htmx-web-component-viewer/spec.md
@@ -0,0 +1,35 @@
+## ADDED Requirements
+
+### Requirement: Petclinic navigable breadcrumbs demonstration
+The Petclinic HTMX sample SHALL demonstrate `<cw-breadcrumbs>` through standard navigable-parent annotations and HTML-authored page composition without application-specific breadcrumb rendering or route code.
+
+#### Scenario: Pet owner page is rendered
+- **WHEN** the Pet Owner custom page renders for Mary Smith
+- **THEN** its breadcrumb landmark contains Mary Smith as the current item with no ancestor link
+
+#### Scenario: Pet page is rendered
+- **WHEN** Mary Smith's pet Basil is rendered
+- **THEN** the breadcrumb landmark contains a Mary Smith ancestor link followed by Basil as the current item
+- **AND** the hierarchy is derived from `Pet.petOwner` marked `Navigable.PARENT`
+
+#### Scenario: Visit page is rendered
+- **WHEN** Basil's scheduled visit is rendered
+- **THEN** the breadcrumb landmark contains Mary Smith and Basil ancestor links in that order followed by the visit as the current item
+- **AND** the hierarchy is derived through `Visit.pet` and then `Pet.petOwner`
+
+#### Scenario: User follows a breadcrumb
+- **WHEN** a user activates the Mary Smith or Basil ancestor link from a descendant route
+- **THEN** the established HTMX semantic navigation bridge installs the corresponding canonical object route
+- **AND** no breadcrumb-specific route handler or URL construction is required
+
+### Requirement: Petclinic breadcrumb regression coverage
+Petclinic integration and browser acceptance SHALL verify navigable annotations, rich GraphQL breadcrumb metadata, component rendering, accessibility, responsive presentation, and canonical navigation while retaining existing custom-page behavior.
+
+#### Scenario: Rich GraphQL hierarchy is queried
+- **WHEN** integration coverage reads metadata for owner, pet, and visit fixtures
+- **THEN** it observes deterministic zero-, one-, and two-ancestor breadcrumb chains
+
+#### Scenario: Browser exercises descendant hierarchy
+- **WHEN** browser automation opens pet and visit routes at desktop and mobile widths
+- **THEN** breadcrumb order, current state, focusable links, no-overflow presentation, and navigation are correct
+- **AND** no unexpected console, page, resource, or GraphQL failure is observed
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md b/openspec/changes/add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
new file mode 100644
index 00000000000..05d74682617
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/specs/graphql-web-component-context/spec.md
@@ -0,0 +1,40 @@
+## ADDED Requirements
+
+### Requirement: Semantic breadcrumbs requirement
+The object context SHALL accept a semantic breadcrumbs read requirement and SHALL translate it to the supported current-object identity, title, and navigable-ancestor metadata fields without exposing generated GraphQL names to the consumer.
+
+#### Scenario: Breadcrumb consumer joins initial composition
+- **WHEN** a breadcrumbs consumer and other domain components register during the same rendering turn
+- **THEN** the context coalesces their selections into one initial object operation
+- **AND** breadcrumb metadata is returned through the consumer's local requirement state
+
+#### Scenario: Breadcrumb consumer connects later
+- **WHEN** breadcrumbs are registered after an object snapshot already exists without their selection
+- **THEN** the context loads only the missing metadata selection
+- **AND** merges it into the immutable current snapshot
+
+#### Scenario: Breadcrumb consumer disconnects
+- **WHEN** the consumer releases its requirement
+- **THEN** the requirement is omitted from subsequent complete refresh projections
+- **AND** release alone causes no network request
+
+### Requirement: Targeted breadcrumb schema discovery
+The GraphQL client SHALL discover the shared breadcrumb-entry object type through targeted introspection only when it is reachable from requested object metadata.
+
+#### Scenario: Metadata supports breadcrumb entries
+- **WHEN** an object description exposes `breadcrumbs` as a list of breadcrumb-entry objects
+- **THEN** targeted discovery loads that entry type and its supported fields
+- **AND** the semantic requirement selects only supported identity and title fields
+
+#### Scenario: Schema lacks breadcrumb support
+- **WHEN** the introspected metadata type has no `breadcrumbs` field or no usable entry identity fields
+- **THEN** the breadcrumb requirement reports an unsupported local state
+- **AND** existing header and member requirements remain usable
+
+### Requirement: Breadcrumb partial-error isolation
+The object context SHALL associate a GraphQL error on navigable breadcrumb metadata with the breadcrumbs requirement while preserving successful sibling metadata and member state.
+
+#### Scenario: Ancestor traversal fails
+- **WHEN** the object response contains current metadata and member data plus an error at the breadcrumb field path
+- **THEN** the breadcrumbs consumer receives a partial-error state
+- **AND** successful header, property, action, and collection consumers retain their normal state
diff --git a/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md b/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
new file mode 100644
index 00000000000..71d8afa0c08
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/specs/rich-graphql-navigable-breadcrumbs/spec.md
@@ -0,0 +1,64 @@
+## ADDED Requirements
+
+### Requirement: Navigable ancestor metadata
+Each rich GraphQL object metadata type SHALL expose a nullable `breadcrumbs` field containing the current object's navigable ancestors as shared breadcrumb-entry objects with non-null `logicalTypeName`, `id`, and `title` fields.
+The entries MUST be ordered from the root ancestor to the immediate parent and MUST NOT repeat the current object.
+
+#### Scenario: Object has a navigable hierarchy
+- **WHEN** the current object's metamodel resolves a parent and that parent resolves another parent
+- **THEN** `breadcrumbs` returns both bookmarkable ancestors in root-to-parent order
+- **AND** each entry contains its current request-local title and bookmark identity
+
+#### Scenario: Object has no navigable parent
+- **WHEN** the current object's metamodel resolves no navigable parent
+- **THEN** `breadcrumbs` returns an empty list
+- **AND** existing current-object metadata remains unchanged
+
+#### Scenario: Navigable parent is unavailable as a navigation target
+- **WHEN** parent traversal reaches null or an object without a bookmark identity
+- **THEN** traversal stops before adding an unusable entry
+- **AND** no Java class name or arbitrary object string is exposed
+
+### Requirement: Facet-authoritative hierarchy traversal
+The rich GraphQL metadata resolver SHALL derive each parent only through the current object's established navigable-parent metamodel semantic, including `@PropertyLayout(navigable = Navigable.PARENT)`, and SHALL NOT infer parents from arbitrary reference properties or persistence relationships.
+
+#### Scenario: Property is the declared navigable parent
+- **WHEN** a domain property is marked `Navigable.PARENT`
+- **THEN** breadcrumb traversal follows the parent returned by the corresponding navigable-parent facet
+- **AND** no duplicate application breadcrumb declaration is required
+
+#### Scenario: Ordinary reference is present
+- **WHEN** an object has a reference property that is not its navigable parent
+- **THEN** that reference does not appear in the breadcrumb chain merely because it is reachable
+
+### Requirement: Bounded and cycle-safe traversal
+Breadcrumb traversal MUST inspect no more than 32 ancestors and MUST detect repeated bookmark identities, including repetition of the current object.
+A cycle, depth overflow, or facet failure SHALL produce a bounded safe error on the nullable `breadcrumbs` field rather than an unbounded operation or a misleading truncated chain.
+
+#### Scenario: Hierarchy contains a cycle
+- **WHEN** traversal encounters an identity already present in the active chain
+- **THEN** the `breadcrumbs` field fails with a bounded cycle diagnostic
+- **AND** traversal terminates without returning the cyclic partial chain
+
+#### Scenario: Hierarchy exceeds the bound
+- **WHEN** traversal would inspect a thirty-third ancestor
+- **THEN** the `breadcrumbs` field fails with a bounded depth diagnostic
+- **AND** it exposes neither the overlong chain nor internal exception details
+
+#### Scenario: Parent facet throws
+- **WHEN** navigable-parent evaluation throws an application exception
+- **THEN** the field reports a bounded safe failure
+- **AND** sibling metadata fields can remain available through GraphQL partial-data behavior
+
+### Requirement: Additive metadata contract
+The breadcrumb projection SHALL be additive to existing rich object metadata and SHALL use one shared breadcrumb-entry GraphQL type rather than one new entry type per domain type.
+
+#### Scenario: Existing client omits breadcrumbs
+- **WHEN** a client executes an established rich object document without selecting `breadcrumbs`
+- **THEN** its operation and response shape remain valid
+- **AND** no navigable-parent traversal occurs
+
+#### Scenario: Schema contains many domain types
+- **WHEN** rich metadata is generated for multiple object types
+- **THEN** all breadcrumb fields reuse the same entry type
+- **AND** schema growth is bounded independently of the domain-type count
diff --git a/openspec/changes/add-breadcrumbs-web-component/tasks.md b/openspec/changes/add-breadcrumbs-web-component/tasks.md
new file mode 100644
index 00000000000..f32917cafed
--- /dev/null
+++ b/openspec/changes/add-breadcrumbs-web-component/tasks.md
@@ -0,0 +1,36 @@
+## 1. Rich GraphQL breadcrumb metadata
+
+- [ ] 1.1 Add one shared GraphQL breadcrumb-entry type and an optional `breadcrumbs` field to generated rich object metadata.
+- [ ] 1.2 Implement root-to-parent traversal through `ObjectSpecification.getNavigableParent`, with bookmark identity, request-local titles, a 32-ancestor bound, cycle detection, and safe failures.
+- [ ] 1.3 Add GraphQL model unit tests for empty, single, multi-level, unbookmarkable, cyclic, over-depth, and throwing parent chains.
+- [ ] 1.4 Extend GraphQL schema and end-to-end contract fixtures to verify additive field shape, shared-type reuse, partial errors, and no traversal when unselected.
+
+## 2. Browser context support
+
+- [ ] 2.1 Extend targeted metadata introspection to discover the shared breadcrumb-entry type without broad schema introspection.
+- [ ] 2.2 Add the semantic breadcrumbs requirement, nested selection translation, state mapping, projection coalescing, delta loading, refresh, and release behavior to `ObjectContextController`.
+- [ ] 2.3 Add browser context and operation tests for supported, unsupported, ready, partial-error, late-registration, and released breadcrumb requirements.
+- [ ] 2.4 Update rich-schema fixtures with deterministic empty, one-level, and multi-level breadcrumb metadata.
+
+## 3. Breadcrumb web component
+
+- [ ] 3.1 Implement and export `<cw-breadcrumbs>` as a context consumer rendering ancestor object links and one escaped current item in an accessible breadcrumb landmark.
+- [ ] 3.2 Register the element and add its public element-name, host-class, stable selector, and semantic navigation contracts.
+- [ ] 3.3 Add responsive breadcrumb styles for ordered separators, focus, wrapping, long titles, narrow layouts, dark mode, and forced colors.
+- [ ] 3.4 Add component tests for loading, empty hierarchy, multiple ancestors, malformed entries, navigation events, escaping, errors, accessibility, and overflow-safe markup.
+- [ ] 3.5 Document plain-HTML usage, GraphQL requirements, hierarchy semantics, error behavior, and framework-neutral navigation ownership.
+
+## 4. Petclinic adoption
+
+- [ ] 4.1 Mark `Pet.petOwner` and `Visit.pet` with the standard `Navigable.PARENT` property-layout semantic.
+- [ ] 4.2 Compose `<cw-breadcrumbs>` into the Pet Owner, Pet, and Visit HTML-authored pages without adding application-specific breadcrumb code.
+- [ ] 4.3 Extend Petclinic GraphQL integration coverage for zero-, one-, and two-ancestor fixture chains and private-page markup coverage for the component.
+- [ ] 4.4 Extend Petclinic Playwright coverage for breadcrumb order, current state, keyboard activation, canonical HTMX navigation, responsive layout, accessibility, and clean browser diagnostics.
+- [ ] 4.5 Update Petclinic documentation and screenshots or stable test hooks where the existing sample contract requires them.
+
+## 5. Validation and archive readiness
+
+- [ ] 5.1 Run focused GraphQL model tests and GraphQL end-to-end tests for breadcrumb metadata.
+- [ ] 5.2 Run foundation unit, component, fixture, lint, and package verification suites.
+- [ ] 5.3 Run HTMX viewer, Petclinic integration, and Petclinic Playwright acceptance suites.
+- [ ] 5.4 Run applicable Maven verification, OpenSpec strict validation, formatting, license, diff, and working-tree checks.
(causeway) 03/07: fix(webcomponents): preserve route focus after menu actions
Posted by da...@apache.org.
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 771eeb3431ea57965176bf59111340a3c61e75df
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:31:25 2026 +0200
fix(webcomponents): preserve route focus after menu actions
---
viewers/webcomponents/foundation/src/menubar-element.mjs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/viewers/webcomponents/foundation/src/menubar-element.mjs b/viewers/webcomponents/foundation/src/menubar-element.mjs
index eb196f667b0..af8fb68c613 100644
--- a/viewers/webcomponents/foundation/src/menubar-element.mjs
+++ b/viewers/webcomponents/foundation/src/menubar-element.mjs
@@ -258,6 +258,12 @@ export class CausewayMenubarElement extends HTMLElementBase {
this._semanticFocusIntent = null;
queueMicrotask(() => {
if (!this.isConnected) return;
+ const activeElement = globalThis.document?.activeElement;
+ if (activeElement
+ && activeElement !== globalThis.document?.body
+ && !this.contains?.(activeElement)) {
+ return;
+ }
if (toolkitControl?.isConnected) {
toolkitControl.focus?.({preventScroll: true});
return;
(causeway) 06/07: fix(webcomponents): remove duplicate field borders
Posted by da...@apache.org.
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 723f85f814b4aec96e27302c17891bdda338ba66
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:54:25 2026 +0200
fix(webcomponents): remove duplicate field borders
---
.../changes/remove-field-double-borders/design.md | 4 +--
.../remove-field-double-borders/proposal.md | 2 +-
.../changes/remove-field-double-borders/tasks.md | 14 +++++-----
viewers/webcomponents/foundation/src/theme.css | 24 ++++++++---------
.../foundation/test/component-styles.test.mjs | 14 ++++++++++
.../petclinic/PetClinicHtmxPlaywrightTest.java | 31 ++++++++++++++++++++++
6 files changed, 67 insertions(+), 22 deletions(-)
diff --git a/openspec/changes/remove-field-double-borders/design.md b/openspec/changes/remove-field-double-borders/design.md
index 57b3d9054e0..e6d10c13d7d 100644
--- a/openspec/changes/remove-field-double-borders/design.md
+++ b/openspec/changes/remove-field-double-borders/design.md
@@ -20,9 +20,9 @@ Vaadin simultaneously renders its own read-only input container, producing the s
## Decisions
-### Exclude toolkit slotted inputs from native-control theme rules
+### Scope application native-control rules away from toolkit internals
-The global theme selectors will exclude controls carrying `slot="input"` wherever it applies border, sizing, focus, padding, or textarea resizing.
+The application-defined global theme selectors will not target controls carrying `slot="input"` wherever they apply border, sizing, focus, padding, or textarea resizing.
The slot marker is the stable integration boundary already emitted by Vaadin field controls, and the exclusion allows Vaadin's own shadow styles to reset and present that internal input correctly.
Editing generated Vaadin assets is rejected because they are deterministic reviewed outputs and regeneration would overwrite the change.
diff --git a/openspec/changes/remove-field-double-borders/proposal.md b/openspec/changes/remove-field-double-borders/proposal.md
index 8ade0f8a58d..b468cfb8831 100644
--- a/openspec/changes/remove-field-double-borders/proposal.md
+++ b/openspec/changes/remove-field-double-borders/proposal.md
@@ -5,7 +5,7 @@ The duplicate boundary is visually distracting and makes one semantic field appe
## What Changes
-- Prevent global native input, select, and textarea rules from styling toolkit-owned slotted field internals.
+- Scope application-defined native input, select, and textarea rules away from toolkit-owned slotted field internals.
- Preserve one toolkit-owned read-only field boundary, genuine read-only semantics, visible focus, and native control styling outside toolkit fields.
- Add regression coverage for the stylesheet boundary and representative Petclinic field presentation.
diff --git a/openspec/changes/remove-field-double-borders/tasks.md b/openspec/changes/remove-field-double-borders/tasks.md
index a8affc13ef7..6ef955e02da 100644
--- a/openspec/changes/remove-field-double-borders/tasks.md
+++ b/openspec/changes/remove-field-double-borders/tasks.md
@@ -1,15 +1,15 @@
## 1. Theme boundary
-- [ ] 1.1 Exclude toolkit-owned `slot="input"` controls from every applicable global native input, select, textarea, focus, padding, and resize selector.
-- [ ] 1.2 Preserve the established global theme rules for ordinary native and fallback controls.
+- [x] 1.1 Scope every applicable application-defined native input, select, textarea, focus, padding, and resize selector away from toolkit-owned `slot="input"` controls.
+- [x] 1.2 Preserve the established global theme rules for ordinary native and fallback controls.
## 2. Regression coverage
-- [ ] 2.1 Add foundation stylesheet tests proving toolkit slotted inputs are excluded and ordinary native controls remain styled.
-- [ ] 2.2 Add Petclinic browser coverage proving representative read-only text fields expose only one visible bordered rectangle.
-- [ ] 2.3 Confirm focus, accessibility, responsive, native-toolkit, and forced-colors behavior remains covered by existing acceptance checks.
+- [x] 2.1 Add foundation stylesheet tests proving toolkit slotted inputs are excluded and ordinary native controls remain styled.
+- [x] 2.2 Add Petclinic browser coverage proving representative read-only text fields expose only one visible bordered rectangle.
+- [x] 2.3 Confirm focus, accessibility, responsive, native-toolkit, and forced-colors behavior remains covered by existing acceptance checks.
## 3. Validation and archive readiness
-- [ ] 3.1 Run foundation verification and Petclinic integration and Playwright acceptance suites.
-- [ ] 3.2 Run strict OpenSpec validation, formatting, license, diff, and working-tree checks.
+- [x] 3.1 Run foundation verification and Petclinic integration and Playwright acceptance suites.
+- [x] 3.2 Run strict OpenSpec validation, formatting, license, diff, and working-tree checks.
diff --git a/viewers/webcomponents/foundation/src/theme.css b/viewers/webcomponents/foundation/src/theme.css
index 575c67278a9..23a59da2914 100644
--- a/viewers/webcomponents/foundation/src/theme.css
+++ b/viewers/webcomponents/foundation/src/theme.css
@@ -163,9 +163,9 @@ a:hover {
}
button,
-input,
-select,
-textarea {
+input:not([slot="input"]),
+select:not([slot="input"]),
+textarea:not([slot="input"]) {
max-width: 100%;
border: 1px solid var(--causeway-border-strong);
border-radius: var(--causeway-radius-sm);
@@ -174,8 +174,8 @@ textarea {
}
button,
-select,
-input:not([type="checkbox"]):not([type="radio"]) {
+select:not([slot="input"]),
+input:not([slot="input"]):not([type="checkbox"]):not([type="radio"]) {
min-height: var(--causeway-control-height);
}
@@ -195,18 +195,18 @@ button:disabled,
opacity: 0.62;
}
-:where(a, button, input, select, textarea, [tabindex]):focus-visible {
+:where(a, button, input:not([slot="input"]), select:not([slot="input"]), textarea:not([slot="input"]), [tabindex]):focus-visible {
outline: 3px solid var(--causeway-focus);
outline-offset: 2px;
}
-input,
-select,
-textarea {
+input:not([slot="input"]),
+select:not([slot="input"]),
+textarea:not([slot="input"]) {
padding: 0.45rem 0.6rem;
}
-textarea {
+textarea:not([slot="input"]) {
min-height: 6rem;
resize: vertical;
}
@@ -429,7 +429,7 @@ cw-property:last-child .causeway-property {
min-width: 0;
}
-.causeway-property-editor > :where(input, select, textarea) {
+.causeway-property-editor > :where(input:not([slot="input"]), select:not([slot="input"]), textarea:not([slot="input"])) {
width: 100%;
}
@@ -638,7 +638,7 @@ cw-interaction-controller:has(.causeway-action-prompt)::before {
gap: var(--causeway-space-1);
}
-.causeway-action-parameter :where(input, select, textarea) {
+.causeway-action-parameter :where(input:not([slot="input"]), select:not([slot="input"]), textarea:not([slot="input"])) {
width: 100%;
}
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index f9eca1661b7..a4906d8218f 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -56,6 +56,20 @@ test('disabled reasons use label-owned tooltips and string values align to logic
assert.match(theme, /\.causeway-property-value-string \{\s+text-align: start;/);
});
+test('application native-control chrome does not cross into toolkit-owned slotted field inputs', async () => {
+ const theme = await readFile(new URL('../src/theme.css', import.meta.url), 'utf8');
+ const nativeInput = 'input:not([slot="input"])';
+ const nativeSelect = 'select:not([slot="input"])';
+ const nativeTextarea = 'textarea:not([slot="input"])';
+
+ assert.ok(theme.includes(`button,\n${nativeInput},\n${nativeSelect},\n${nativeTextarea} {\n max-width: 100%;\n border: 1px solid var(--causeway-border-strong);`));
+ assert.ok(theme.includes(`${nativeInput},\n${nativeSelect},\n${nativeTextarea} {\n padding: 0.45rem 0.6rem;`));
+ assert.ok(theme.includes(`${nativeTextarea} {\n min-height: 6rem;\n resize: vertical;`));
+ assert.ok(theme.includes(`:where(a, button, ${nativeInput}, ${nativeSelect}, ${nativeTextarea}, [tabindex]):focus-visible`));
+ assert.doesNotMatch(theme, /\ninput,\nselect,\ntextarea \{\n max-width:/);
+ assert.doesNotMatch(theme, /:where\(a, button, input, select, textarea, \[tabindex\]\):focus-visible/);
+});
+
test('direct member associations wrap in semantic source and keyboard order', () => {
assert.match(CAUSEWAY_COMPONENT_STYLES, /cw-property\[data-causeway-action-group\]/);
assert.match(CAUSEWAY_COMPONENT_STYLES, /cw-collection\[data-causeway-action-group\]/);
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 33463d565da..544ad578641 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
@@ -234,6 +234,8 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
assertThat(page.locator("cw-collection[id='visits'] > cw-action[id='bookVisit']").count())
.isEqualTo(1);
assertDefaultOrNativeMemberPresentation("name", "delete");
+ assertSingleToolkitFieldBoundary("name");
+ assertSingleToolkitFieldBoundary("telephoneNumber");
assertThat(page.locator("cw-collection[id='pets']")
.evaluate("element => [...element.children].filter(child => child.localName === 'cw-action').map(child => child.getAttribute('id')).join(',')"))
.isEqualTo("addPet,removePet");
@@ -883,6 +885,35 @@ private void waitForPromptError(final String messagePart) {
alert.waitFor();
}
+ @SuppressWarnings("unchecked")
+ private void assertSingleToolkitFieldBoundary(final String member) {
+ final var property = page.locator("cw-property[id='" + member + "']");
+ if (nativeToolkit()) {
+ assertThat(property.locator("cw-field-editor").count()).isZero();
+ return;
+ }
+ page.waitForFunction("member => document.querySelector(`cw-property[id='${member}'] cw-field-editor[data-mode='view'] > [data-causeway-field-view]`)?.shadowRoot?.querySelector('[part~=input-field]')", member);
+ final var borderWidths = (List<Number>) property.evaluate("""
+ element => {
+ const control = element.querySelector('cw-field-editor[data-mode="view"] > [data-causeway-field-view]');
+ const container = control.shadowRoot.querySelector('[part~="input-field"]');
+ const input = control.querySelector('[slot="input"]');
+ const borderWidth = candidate => {
+ const style = getComputedStyle(candidate);
+ return ['Top', 'Right', 'Bottom', 'Left']
+ .reduce((total, side) => total + Number.parseFloat(style[`border${side}Width`]), 0);
+ };
+ return [borderWidth(container), borderWidth(input)];
+ }
+ """);
+ assertThat(borderWidths.get(0).doubleValue())
+ .as("toolkit field boundary for %s", member)
+ .isGreaterThan(0.0);
+ assertThat(borderWidths.get(1).doubleValue())
+ .as("nested native input boundary for %s", member)
+ .isZero();
+ }
+
@SuppressWarnings("unchecked")
private void assertWideMultilinePropertyLayout(final Locator property) {
final var geometry = (List<Number>) property.evaluate("element => { const label = element.querySelector('.causeway-property-label').getBoundingClientRect(); const description = element.querySelector('.causeway-property-description').getBoundingClientRect(); const value = element.querySelector('.causeway-property-value').getBoundingClientRect(); const edit = element.querySelector('.causeway-property-edit').getBoundingClientRect(); return [label.left, label.right, label.bottom, des [...]
(causeway) 05/07: docs(openspec): propose single field boundaries
Posted by da...@apache.org.
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 5ac4368599d182bd9c6fe0b9b5729696326e1467
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 11:41:40 2026 +0200
docs(openspec): propose single field boundaries
---
.../remove-field-double-borders/.openspec.yaml | 2 +
.../changes/remove-field-double-borders/design.md | 51 ++++++++++++++++++++++
.../remove-field-double-borders/proposal.md | 25 +++++++++++
.../vaadin-semantic-presentation-adapters/spec.md | 24 ++++++++++
.../changes/remove-field-double-borders/tasks.md | 15 +++++++
5 files changed, 117 insertions(+)
diff --git a/openspec/changes/remove-field-double-borders/.openspec.yaml b/openspec/changes/remove-field-double-borders/.openspec.yaml
new file mode 100644
index 00000000000..50adc91034f
--- /dev/null
+++ b/openspec/changes/remove-field-double-borders/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-29
diff --git a/openspec/changes/remove-field-double-borders/design.md b/openspec/changes/remove-field-double-borders/design.md
new file mode 100644
index 00000000000..57b3d9054e0
--- /dev/null
+++ b/openspec/changes/remove-field-double-borders/design.md
@@ -0,0 +1,51 @@
+## Context
+
+The foundation theme applies shared native-control rules to every `input`, `select`, and `textarea` in document light DOM.
+Vaadin fields create their native form element as a light-DOM node assigned to `slot="input"`, so those broad rules cross the internal adapter boundary.
+Vaadin simultaneously renders its own read-only input container, producing the solid inner border and dashed outer boundary shown in the supplied Petclinic screenshots.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Keep one coherent Vaadin-owned field boundary for toolkit-backed read-only values.
+- Keep global Causeway styling on native application and fallback controls.
+- Preserve genuine `readonly` state, accessibility, focus, responsive sizing, and editor behavior.
+
+**Non-Goals:**
+
+- Change Vaadin generated assets or third-party styles.
+- Remove all field boundaries or focus indicators.
+- Change public component markup, toolkit policy, or native-mode presentation.
+
+## Decisions
+
+### Exclude toolkit slotted inputs from native-control theme rules
+
+The global theme selectors will exclude controls carrying `slot="input"` wherever it applies border, sizing, focus, padding, or textarea resizing.
+The slot marker is the stable integration boundary already emitted by Vaadin field controls, and the exclusion allows Vaadin's own shadow styles to reset and present that internal input correctly.
+
+Editing generated Vaadin assets is rejected because they are deterministic reviewed outputs and regeneration would overwrite the change.
+Removing `readonly` is rejected because it would weaken semantics and permit interaction in control families that support value changes.
+Removing Vaadin's outer boundary is rejected because the toolkit owns that presentation and its focus, forced-colors, and read-only treatment.
+
+### Verify both selector scope and rendered result
+
+Foundation stylesheet tests will assert that slotted toolkit inputs are excluded while ordinary controls remain covered.
+Petclinic browser acceptance will inspect representative read-only text and telephone fields and require only one visible bordered rectangle.
+Existing accessibility and native-toolkit checks remain authoritative.
+
+## Risks / Trade-offs
+
+- [Risk] A future toolkit uses a different slot name. → Keep the regression test tied to the reviewed adapter contract and fail visibly when integration markup changes.
+- [Risk] Excluding slotted controls could remove intended application styling from unrelated custom elements. → Scope the exclusion only to `slot="input"`, which denotes an internal field input, and retain host-level component variables.
+- [Risk] Border counting can vary with forced-colors or focus state. → Test the ordinary unfocused read-only presentation and retain existing forced-colors and focus qualification separately.
+
+## Migration Plan
+
+Ship the stylesheet selector change with no markup or configuration migration.
+Rollback restores the broad native-control selectors.
+
+## Open Questions
+
+None.
diff --git a/openspec/changes/remove-field-double-borders/proposal.md b/openspec/changes/remove-field-double-borders/proposal.md
new file mode 100644
index 00000000000..8ade0f8a58d
--- /dev/null
+++ b/openspec/changes/remove-field-double-borders/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+Toolkit-backed read-only fields currently show two nested borders because the global Causeway native-control theme also styles Vaadin's slotted internal input while Vaadin renders its own field boundary.
+The duplicate boundary is visually distracting and makes one semantic field appear to be two controls.
+
+## What Changes
+
+- Prevent global native input, select, and textarea rules from styling toolkit-owned slotted field internals.
+- Preserve one toolkit-owned read-only field boundary, genuine read-only semantics, visible focus, and native control styling outside toolkit fields.
+- Add regression coverage for the stylesheet boundary and representative Petclinic field presentation.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `vaadin-semantic-presentation-adapters`: Require toolkit-backed read-only fields to present one coherent control boundary without inheriting duplicate native-control chrome.
+
+## Impact
+
+The change affects the foundation theme stylesheet, its stylesheet tests, and browser acceptance assertions for Vaadin-backed read-only field presentation.
+It does not change component markup, GraphQL behavior, editor selection, native toolkit presentation, public selectors, or third-party dependencies.
diff --git a/openspec/changes/remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md b/openspec/changes/remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
new file mode 100644
index 00000000000..7f722bfa7cc
--- /dev/null
+++ b/openspec/changes/remove-field-double-borders/specs/vaadin-semantic-presentation-adapters/spec.md
@@ -0,0 +1,24 @@
+## ADDED Requirements
+
+### Requirement: Single toolkit field boundary
+A toolkit-backed read-only field SHALL present one coherent toolkit-owned control boundary and MUST NOT inherit native-control border, padding, sizing, focus, or resize chrome on its slotted internal form element.
+The internal control MUST retain genuine read-only semantics, accessible relationships, responsive sizing, theme variables, visible focus, and forced-colors behavior.
+
+#### Scenario: Read-only text field is rendered
+- **WHEN** an eligible string property uses the Vaadin read-only field adapter
+- **THEN** the field presents one visible bordered rectangle around its value
+- **AND** no separately bordered native input appears inside that rectangle
+
+#### Scenario: Read-only multiline, choice, numeric, or temporal field is rendered
+- **WHEN** another qualified field family creates a native form element in its reviewed internal input slot
+- **THEN** global native-control rules do not add a duplicate nested boundary or padding
+- **AND** the toolkit retains ownership of that field's read-only presentation
+
+#### Scenario: Native control is rendered outside a toolkit field
+- **WHEN** a Causeway fallback, editor, prompt, shell, or application control is not a toolkit-owned slotted internal input
+- **THEN** the established native theme border, sizing, padding, and focus rules continue to apply
+
+#### Scenario: Field receives keyboard focus or forced-colors presentation
+- **WHEN** the toolkit-backed field exposes its supported focus or forced-colors state
+- **THEN** its toolkit-owned visible indicator remains present and distinguishable
+- **AND** the duplicate native-control chrome does not reappear
diff --git a/openspec/changes/remove-field-double-borders/tasks.md b/openspec/changes/remove-field-double-borders/tasks.md
new file mode 100644
index 00000000000..a8affc13ef7
--- /dev/null
+++ b/openspec/changes/remove-field-double-borders/tasks.md
@@ -0,0 +1,15 @@
+## 1. Theme boundary
+
+- [ ] 1.1 Exclude toolkit-owned `slot="input"` controls from every applicable global native input, select, textarea, focus, padding, and resize selector.
+- [ ] 1.2 Preserve the established global theme rules for ordinary native and fallback controls.
+
+## 2. Regression coverage
+
+- [ ] 2.1 Add foundation stylesheet tests proving toolkit slotted inputs are excluded and ordinary native controls remain styled.
+- [ ] 2.2 Add Petclinic browser coverage proving representative read-only text fields expose only one visible bordered rectangle.
+- [ ] 2.3 Confirm focus, accessibility, responsive, native-toolkit, and forced-colors behavior remains covered by existing acceptance checks.
+
+## 3. Validation and archive readiness
+
+- [ ] 3.1 Run foundation verification and Petclinic integration and Playwright acceptance suites.
+- [ ] 3.2 Run strict OpenSpec validation, formatting, license, diff, and working-tree checks.