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