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,7 +294,7 @@ 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 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/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/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..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 @@ -94,6 +105,7 @@ export class RenderableSearchEntry { private fallbackFormat: PrerenderedHtmlFormat, private mode: HydrationMode, private overlays: boolean, + private displayContainer: boolean, ) {} get id(): string { @@ -201,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, @@ -214,6 +230,7 @@ export class RenderableSearchEntry { errorDoc: this.errorDoc, mode: this.mode, overlays: this.overlays, + displayContainer: this.displayContainer, }); } return this.#component; @@ -242,6 +259,7 @@ export class RenderableSearchEntries { private resource: ReturnType, private getMode: () => HydrationMode, private getOverlays: () => boolean, + private getDisplayContainer: () => boolean, ) {} private get fallbackRenderType(): ResolvedCodeRef | undefined { @@ -257,9 +275,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 +302,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 +338,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 +351,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..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 @@ -200,6 +200,56 @@ module( ); }); + test('@displayContainer=false reaches the rows through the card-facing wrapper', async function (assert) { + // 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], + }; + await render( + , + ); + await waitUntil(() => + Boolean(document.querySelector('[data-test-search-result]')), + ); + + assert + .dom(`[data-test-hydratable-card="${BOOK_1}"]`) + .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}"]`, + '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/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 a3b7580bd34..ab757b4487a 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,73 @@ module('Integration | Component | hydratable-card', function (hooks) { ); }); + // The boundary ring is on by default and survives hydration: the inert HTML + // 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( + , + ); + + assert + .dom('[data-test-inert-card]') + .hasClass('boxel-card-container--boundaries', 'inert ring is kept'); + + 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}}` 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( + , + ); + + assert + .dom('[data-test-inert-card]') + .exists('the inert HTML renders as handed in'); + + 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];