From 1680e68008717cf5cc850846dca809f413415390 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Thu, 10 Sep 2026 15:24:15 -0400 Subject: [PATCH 1/2] Add displayContainer option to search results component Co-Authored-By: Claude Fable 5.1 --- .../search/card-context-search-results.gts | 2 + .../app/components/search/hydratable-card.gts | 13 +++- .../app/components/search/search-results.gts | 6 ++ .../app/lib/hydratable-entry-component.ts | 6 ++ .../resources/renderable-search-entries.ts | 8 +++ .../card-context-search-results-test.gts | 41 +++++++++++ .../components/hydratable-card-test.gts | 69 +++++++++++++++++++ .../search-results-component.ts | 5 ++ 8 files changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/host/app/components/search/card-context-search-results.gts b/packages/host/app/components/search/card-context-search-results.gts index ab6077d0c34..cf158d5fcbc 100644 --- a/packages/host/app/components/search/card-context-search-results.gts +++ b/packages/host/app/components/search/card-context-search-results.gts @@ -36,6 +36,7 @@ export default class CardContextSearchResults extends Component { return cardContext; } + private get displayContainer(): boolean { + return this.args.displayContainer ?? true; + } + @action private hydrate() { if (this.args.isError) { return; @@ -284,13 +294,14 @@ export default class HydratableCard extends Component { @card={{this.liveCard}} @format={{this.format}} @codeRef={{@renderType}} - @displayContainer={{false}} + @displayContainer={{this.displayContainer}} data-hydration={{this.hydrationState}} data-test-hydratable-card={{@cardId}} ...attributes /> {{else if @component}} <@component + class={{unless this.displayContainer 'hide-boundaries'}} {{hydrationTrigger this.mode this.hydrate}} {{this.trackElement cardId=@cardId diff --git a/packages/host/app/components/search/search-results.gts b/packages/host/app/components/search/search-results.gts index 507f4139d77..9366783386a 100644 --- a/packages/host/app/components/search/search-results.gts +++ b/packages/host/app/components/search/search-results.gts @@ -65,6 +65,10 @@ export default class SearchResults extends Component return this.args.overlays ?? true; } + private get displayContainer(): boolean { + return this.args.displayContainer ?? true; + } + // Created once per component: the view-model layer memoizes render-stable // entries on top of a search resource. With `@resource` it wraps the // caller-owned resource (whose subscriptions and re-runs outlive this @@ -77,12 +81,14 @@ export default class SearchResults extends Component this.args.resource, () => this.mode, () => this.overlays, + () => this.displayContainer, ) : getRenderableSearchEntries( this, () => this.args.query, () => this.mode, () => this.overlays, + () => this.displayContainer, { cardInitiated: this.args.cardInitiated, getDefaultRealm: this.args.getDefaultRealm, diff --git a/packages/host/app/lib/hydratable-entry-component.ts b/packages/host/app/lib/hydratable-entry-component.ts index 651b5249209..58f1acd88f6 100644 --- a/packages/host/app/lib/hydratable-entry-component.ts +++ b/packages/host/app/lib/hydratable-entry-component.ts @@ -57,6 +57,9 @@ export interface HydratableEntryArgs { // Whether the row registers with the operator-mode overlay; `false` renders // it plainly (no chip / options menu / selection toggle). overlays: boolean; + // Whether the row renders its card container chrome (the boundary ring); + // `false` hides it, as `@displayContainer={{false}}` does on `@fields`. + displayContainer: boolean; } class _HydratableEntryComponent { @@ -71,6 +74,7 @@ class _HydratableEntryComponent { readonly errorDoc: ErrorEntry | undefined, readonly mode: HydrationMode, readonly overlays: boolean, + readonly displayContainer: boolean, ) {} } @@ -87,6 +91,7 @@ setComponentTemplate( @errorDoc={{this.errorDoc}} @mode={{this.mode}} @overlays={{this.overlays}} + @displayContainer={{this.displayContainer}} ...attributes />`, { @@ -133,5 +138,6 @@ export function hydratableEntryComponent( args.errorDoc, args.mode, args.overlays, + args.displayContainer, ) as unknown as EntryComponent; } diff --git a/packages/host/app/resources/renderable-search-entries.ts b/packages/host/app/resources/renderable-search-entries.ts index e9e697f8346..46b791bb7d4 100644 --- a/packages/host/app/resources/renderable-search-entries.ts +++ b/packages/host/app/resources/renderable-search-entries.ts @@ -94,6 +94,7 @@ export class RenderableSearchEntry { private fallbackFormat: PrerenderedHtmlFormat, private mode: HydrationMode, private overlays: boolean, + private displayContainer: boolean, ) {} get id(): string { @@ -214,6 +215,7 @@ export class RenderableSearchEntry { errorDoc: this.errorDoc, mode: this.mode, overlays: this.overlays, + displayContainer: this.displayContainer, }); } return this.#component; @@ -242,6 +244,7 @@ export class RenderableSearchEntries { private resource: ReturnType, private getMode: () => HydrationMode, private getOverlays: () => boolean, + private getDisplayContainer: () => boolean, ) {} private get fallbackRenderType(): ResolvedCodeRef | undefined { @@ -257,9 +260,11 @@ export class RenderableSearchEntries { let fallbackFormat = this.fallbackFormat; let mode = this.getMode(); let overlays = this.getOverlays(); + let displayContainer = this.getDisplayContainer(); let inputsKey = JSON.stringify([ mode, overlays, + displayContainer, fallbackRenderType, fallbackFormat, ]); @@ -282,6 +287,7 @@ export class RenderableSearchEntries { fallbackFormat, mode, overlays, + displayContainer, ); // Pure memoization keyed on the resource's stable entry identity — it // dirties no tracked state, and keeping unchanged rows' view-models (and @@ -317,6 +323,7 @@ export function getRenderableSearchEntries( getQuery: () => SearchEntryWireQuery | undefined, getMode: () => HydrationMode, getOverlays: () => boolean = () => true, + getDisplayContainer: () => boolean = () => true, opts?: { // Forwarded to the underlying resource: scope a no-realm card search to the // current realm instead of fanning out. Set only by the card-facing @@ -329,5 +336,6 @@ export function getRenderableSearchEntries( getSearchEntriesResource(owner, getQuery, opts), getMode, getOverlays, + getDisplayContainer, ); } diff --git a/packages/host/tests/integration/components/card-context-search-results-test.gts b/packages/host/tests/integration/components/card-context-search-results-test.gts index 7bd90cefe8a..cacf1fe267f 100644 --- a/packages/host/tests/integration/components/card-context-search-results-test.gts +++ b/packages/host/tests/integration/components/card-context-search-results-test.gts @@ -200,6 +200,47 @@ module( ); }); + test('@displayContainer=false reaches the rows through the card-facing wrapper', async function (assert) { + // The wrapper forwards the arg explicitly; a row must lose its container + // boundaries both while inert and once hydrated. + let query: SearchEntryWireQuery = { + filter: { 'item.on': bookRef }, + realms: [testRealmURL], + }; + await render( + , + ); + await waitUntil(() => + Boolean(document.querySelector('[data-test-search-result]')), + ); + + assert + .dom(`[data-test-hydratable-card="${BOOK_1}"]`) + .hasClass('hide-boundaries', 'the inert row suppresses its ring'); + + await triggerEvent( + `[data-test-hydratable-card="${BOOK_1}"]`, + 'mouseenter', + ); + + assert + .dom( + `[data-test-hydratable-card="${BOOK_1}"][data-hydration="hydrated"]`, + ) + .doesNotHaveClass( + 'boxel-card-container--boundaries', + 'the live container renders without boundaries', + ); + }); + test('a no-realm card search scopes to the context default realm', async function (assert) { // The query carries no `realms`. Card-initiated, it targets the realm the // `@context` was provided with rather than fanning out across every diff --git a/packages/host/tests/integration/components/hydratable-card-test.gts b/packages/host/tests/integration/components/hydratable-card-test.gts index a3b7580bd34..52019bba66f 100644 --- a/packages/host/tests/integration/components/hydratable-card-test.gts +++ b/packages/host/tests/integration/components/hydratable-card-test.gts @@ -71,6 +71,9 @@ class TestContext extends GlimmerComponent { const HASSAN = `${testRealmURL}Person/hassan`; const INERT_HTML = `
Inert
`; +// Inert HTML as the prerender captures it: the card-api container with its +// boundary class already on it. +const BOUNDED_INERT_HTML = `
Inert
`; // Drives a mount/unmount toggle so a teardown test can destroy the rendered // HydratableCard and assert it releases its Store reference. @@ -294,6 +297,72 @@ module('Integration | Component | hydratable-card', function (hooks) { ); }); + // The boundary ring is on by default and survives hydration: the inert HTML + // keeps whatever boundary class it was prerendered with, and the live card + // renders inside its own bounded container. + test('displayContainer defaults to true — the hydrated card keeps its container boundaries', async function (assert) { + let inert = htmlComponent(BOUNDED_INERT_HTML); + await render( + , + ); + + assert + .dom('[data-test-inert-card]') + .hasClass('boxel-card-container--boundaries', 'inert ring is kept') + .doesNotHaveClass('hide-boundaries', 'nothing hides the inert ring'); + + await triggerEvent('[data-test-hydratable-card]', 'mouseenter'); + + assert + .dom('[data-test-hydratable-card]') + .hasClass( + 'boxel-card-container--boundaries', + 'the live container draws its boundaries too', + ); + }); + + // `@displayContainer={{false}}` hides the ring on both sides of the swap, the + // way `<@fields.x @displayContainer={{false}} />` does for a field render. + test('displayContainer=false — hides the boundaries on the inert HTML and the live card alike', async function (assert) { + let inert = htmlComponent(BOUNDED_INERT_HTML); + await render( + , + ); + + assert + .dom('[data-test-inert-card]') + .hasClass('hide-boundaries', 'the inert ring is suppressed'); + + await triggerEvent('[data-test-hydratable-card]', 'mouseenter'); + + assert + .dom('[data-test-live-card]') + .hasText('Live: Hassan', 'still hydrates'); + assert + .dom('[data-test-hydratable-card]') + .doesNotHaveClass( + 'boxel-card-container--boundaries', + 'the live container renders without boundaries', + ); + }); + // `none` stays inert with the diagnostic attribute and never fetches. test('none — stays inert, marks data-hydration=none, and never fetches', async function (assert) { let inert = htmlComponent(INERT_HTML); diff --git a/packages/runtime-common/search-results-component.ts b/packages/runtime-common/search-results-component.ts index 19a66dc3103..431b1717f19 100644 --- a/packages/runtime-common/search-results-component.ts +++ b/packages/runtime-common/search-results-component.ts @@ -102,6 +102,11 @@ export interface SearchResultsComponentSignature { // pass `false` for a card that lays results out in its own UI and wants // them rendered plainly, with no overlay even inside operator mode. overlays?: boolean; + // Whether each result renders inside its card container chrome (the + // boundary ring), the same switch `<@fields.x @displayContainer={{false}} />` + // offers. Defaults to `true`; pass `false` for a consumer that frames + // results itself. + displayContainer?: boolean; }; Blocks: { default: [SearchResultsYield]; From d64fecb583fd3082f94185089733ac8294eed878 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 11 Sep 2026 15:19:56 -0400 Subject: [PATCH 2/2] Match inert search rows to the live card when displayContainer is off Co-Authored-By: Claude Fable 5.1 --- .../app/components/search/hydratable-card.gts | 11 ++--- packages/host/app/lib/html-component.ts | 16 +++++-- .../resources/renderable-search-entries.ts | 17 ++++++- .../card-context-search-results-test.gts | 15 ++++-- .../components/html-component-test.gts | 47 +++++++++++++++++++ .../components/hydratable-card-test.gts | 19 ++++---- 6 files changed, 103 insertions(+), 22 deletions(-) diff --git a/packages/host/app/components/search/hydratable-card.gts b/packages/host/app/components/search/hydratable-card.gts index bd87d49f462..1aa44a77ded 100644 --- a/packages/host/app/components/search/hydratable-card.gts +++ b/packages/host/app/components/search/hydratable-card.gts @@ -100,11 +100,11 @@ interface Signature { // and no overlay (chip / options menu / selection toggle) ever anchors to // it — for a consumer that lays results out in its own UI. overlays?: boolean; - // Whether the row renders its card container chrome — the boundary ring - // (defaults to `true`). `false` hides it on both the inert HTML (via the - // container's `hide-boundaries` class) and the hydrated live card (via - // `@displayContainer={{false}}`), matching the `@fields` switch, so a row - // looks the same before and after hydration either way. + // Whether the hydrated live card renders its card container chrome + // (defaults to `true`), matching the `@fields` switch. The inert HTML is + // not touched here — a caller passing `false` hands in `@component` with + // its container classes already rewritten (see `RenderableSearchEntry`), + // so a row looks the same before and after hydration. displayContainer?: boolean; // The format the live/hydrated card renders as, so it matches the // prerendered HTML the query selected (defaults to `fitted`). @@ -301,7 +301,6 @@ export default class HydratableCard extends Component { /> {{else if @component}} <@component - class={{unless this.displayContainer 'hide-boundaries'}} {{hydrationTrigger this.mode this.hydrate}} {{this.trackElement cardId=@cardId diff --git a/packages/host/app/lib/html-component.ts b/packages/host/app/lib/html-component.ts index 8f61b2e8650..91a53b02ee4 100644 --- a/packages/host/app/lib/html-component.ts +++ b/packages/host/app/lib/html-component.ts @@ -39,14 +39,24 @@ type TopElement = ComponentLike<{ export function htmlComponent( html: string, extraAttributes: Record = {}, + // Runs on the parsed root before its attributes are read into the template, + // so a caller can adjust the root's classes without reserializing the HTML. + transformRoot?: (root: Element) => void, ): HTMLComponent { let testContainer = document.createElement('div'); testContainer.innerHTML = html; + // Prerendered atom / isolated / head HTML is captured as the render root's + // innerHTML and so arrives wrapped in the route template's whitespace; only + // non-blank text counts against the single-root shape. + let significantNodes = [...testContainer.childNodes].filter( + (node) => node.nodeType !== Node.TEXT_NODE || node.textContent?.trim(), + ); if ( - testContainer.childNodes.length === 1 && - testContainer.children.length === 1 + significantNodes.length === 1 && + significantNodes[0].nodeType === Node.ELEMENT_NODE ) { - let cardElement = testContainer.children[0]; + let cardElement = significantNodes[0] as Element; + transformRoot?.(cardElement); let tagName = cardElement.tagName.toLowerCase(); let sourceParts: string[] = []; diff --git a/packages/host/app/resources/renderable-search-entries.ts b/packages/host/app/resources/renderable-search-entries.ts index 46b791bb7d4..503974072a6 100644 --- a/packages/host/app/resources/renderable-search-entries.ts +++ b/packages/host/app/resources/renderable-search-entries.ts @@ -52,6 +52,17 @@ function extraAttributesFor( return attrs; } +// Prerendered HTML always captures its root container with the chrome on +// (`--boundaries` ring, `display-container-true` layout). A row rendered with +// `displayContainer: false` swaps those for exactly the classes the live card +// renders with under `@displayContainer={{false}}`, so the inert and hydrated +// states share one layout — for an atom that is `display: contents` versus an +// inline-block with padding, not just a missing ring. +function removeContainerChrome(root: Element): void { + root.classList.remove('boxel-card-container--boundaries'); + root.classList.replace('display-container-true', 'display-container-false'); +} + // The query's requested render type, echoed once at the document level. Used // to render an item-only (live) fallback as the same ancestor its HTML // siblings would have rendered as. Only a single `eq` leaf names one type; a @@ -202,7 +213,11 @@ export class RenderableSearchEntry { let { html } = this; let inert = html && html.html != null - ? htmlComponent(html.html, extraAttributesFor(html, this.iconHtml)) + ? htmlComponent( + html.html, + extraAttributesFor(html, this.iconHtml), + this.displayContainer ? undefined : removeContainerChrome, + ) : undefined; this.#component = hydratableEntryComponent({ cardId: this.id, diff --git a/packages/host/tests/integration/components/card-context-search-results-test.gts b/packages/host/tests/integration/components/card-context-search-results-test.gts index cacf1fe267f..053cb61dd76 100644 --- a/packages/host/tests/integration/components/card-context-search-results-test.gts +++ b/packages/host/tests/integration/components/card-context-search-results-test.gts @@ -201,8 +201,9 @@ module( }); test('@displayContainer=false reaches the rows through the card-facing wrapper', async function (assert) { - // The wrapper forwards the arg explicitly; a row must lose its container - // boundaries both while inert and once hydrated. + // The wrapper forwards the arg explicitly; a row must carry the same + // container-off classes while inert as the live card renders with, so + // the swap causes no layout shift. let query: SearchEntryWireQuery = { filter: { 'item.on': bookRef }, realms: [testRealmURL], @@ -224,7 +225,15 @@ module( assert .dom(`[data-test-hydratable-card="${BOOK_1}"]`) - .hasClass('hide-boundaries', 'the inert row suppresses its ring'); + .doesNotHaveClass( + 'boxel-card-container--boundaries', + 'the inert row has no ring', + ) + .hasClass( + 'display-container-false', + 'the inert row carries the container-off layout class', + ) + .doesNotHaveClass('display-container-true'); await triggerEvent( `[data-test-hydratable-card="${BOOK_1}"]`, diff --git a/packages/host/tests/integration/components/html-component-test.gts b/packages/host/tests/integration/components/html-component-test.gts index a52db030b55..d41ba19a29b 100644 --- a/packages/host/tests/integration/components/html-component-test.gts +++ b/packages/host/tests/integration/components/html-component-test.gts @@ -28,4 +28,51 @@ module('Integration | Component | html-component', function (hooks) { .dom('[data-test-second-mount] [data-test-inert-body]') .hasText('Body', 'the second mount has its own content'); }); + + // Prerendered atom HTML arrives wrapped in the route template's whitespace. + // It must still resolve to a single root so splatted attributes land on it. + test('surrounding whitespace does not stop the root element from taking attributes', async function (assert) { + let Inert = htmlComponent( + `\n \n
Body
\n\n `, + ); + + await render(); + + assert + .dom('[data-test-root]') + .exists('the splatted attribute reached the root') + .hasClass('card', 'the root is the prerendered element') + .hasText('Body'); + }); + + test('transformRoot edits the parsed root before it is rendered', async function (assert) { + let Inert = htmlComponent( + `
Body
`, + {}, + (root) => root.classList.replace('ring', 'plain'), + ); + + await render(); + + assert + .dom('[data-test-root]') + .hasClass('plain', 'the replacement class is rendered') + .doesNotHaveClass('ring', 'the original class is gone'); + }); + + test('transformRoot is skipped when the HTML has no single root', async function (assert) { + let calls = 0; + let Inert = htmlComponent(`OneTwo`, {}, () => { + calls++; + }); + + await render( + , + ); + + assert.strictEqual(calls, 0, 'the hook never ran'); + assert.dom('[data-test-wrap]').hasText('OneTwo', 'the HTML still renders'); + }); }); diff --git a/packages/host/tests/integration/components/hydratable-card-test.gts b/packages/host/tests/integration/components/hydratable-card-test.gts index 52019bba66f..ab757b4487a 100644 --- a/packages/host/tests/integration/components/hydratable-card-test.gts +++ b/packages/host/tests/integration/components/hydratable-card-test.gts @@ -298,8 +298,8 @@ module('Integration | Component | hydratable-card', function (hooks) { }); // The boundary ring is on by default and survives hydration: the inert HTML - // keeps whatever boundary class it was prerendered with, and the live card - // renders inside its own bounded container. + // is rendered as handed in, and the live card renders inside its own bounded + // container. test('displayContainer defaults to true — the hydrated card keeps its container boundaries', async function (assert) { let inert = htmlComponent(BOUNDED_INERT_HTML); await render( @@ -316,8 +316,7 @@ module('Integration | Component | hydratable-card', function (hooks) { assert .dom('[data-test-inert-card]') - .hasClass('boxel-card-container--boundaries', 'inert ring is kept') - .doesNotHaveClass('hide-boundaries', 'nothing hides the inert ring'); + .hasClass('boxel-card-container--boundaries', 'inert ring is kept'); await triggerEvent('[data-test-hydratable-card]', 'mouseenter'); @@ -329,10 +328,12 @@ module('Integration | Component | hydratable-card', function (hooks) { ); }); - // `@displayContainer={{false}}` hides the ring on both sides of the swap, the - // way `<@fields.x @displayContainer={{false}} />` does for a field render. - test('displayContainer=false — hides the boundaries on the inert HTML and the live card alike', async function (assert) { - let inert = htmlComponent(BOUNDED_INERT_HTML); + // `@displayContainer={{false}}` reaches the live card the way + // `<@fields.x @displayContainer={{false}} />` does for a field render. The + // inert HTML is the caller's to shape (RenderableSearchEntry rewrites its + // container classes), so it renders exactly as handed in. + test('displayContainer=false — the hydrated card renders without container boundaries', async function (assert) { + let inert = htmlComponent(INERT_HTML); await render(