Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default class CardContextSearchResults extends Component<SearchResultsCom
@query={{@query}}
@mode={{@mode}}
@overlays={{@overlays}}
@displayContainer={{@displayContainer}}
@cardInitiated={{true}}
@getDefaultRealm={{this.getDefaultRealm}}
...attributes
Expand All @@ -48,6 +49,7 @@ export default class CardContextSearchResults extends Component<SearchResultsCom
@query={{@query}}
@mode={{@mode}}
@overlays={{@overlays}}
@displayContainer={{@displayContainer}}
@cardInitiated={{true}}
@getDefaultRealm={{this.getDefaultRealm}}
...attributes
Expand Down
12 changes: 11 additions & 1 deletion packages/host/app/components/search/hydratable-card.gts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ 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 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`).
format?: Format;
Expand Down Expand Up @@ -266,6 +272,10 @@ export default class HydratableCard extends Component<Signature> {
return cardContext;
}

private get displayContainer(): boolean {
return this.args.displayContainer ?? true;
}

@action private hydrate() {
if (this.args.isError) {
return;
Expand All @@ -284,7 +294,7 @@ export default class HydratableCard extends Component<Signature> {
@card={{this.liveCard}}
@format={{this.format}}
@codeRef={{@renderType}}
@displayContainer={{false}}
@displayContainer={{this.displayContainer}}
data-hydration={{this.hydrationState}}
data-test-hydratable-card={{@cardId}}
...attributes
Expand Down
6 changes: 6 additions & 0 deletions packages/host/app/components/search/search-results.gts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export default class SearchResults extends Component<HostSearchResultsSignature>
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
Expand All @@ -77,12 +81,14 @@ export default class SearchResults extends Component<HostSearchResultsSignature>
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,
Expand Down
16 changes: 13 additions & 3 deletions packages/host/app/lib/html-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,24 @@ type TopElement = ComponentLike<{
export function htmlComponent(
html: string,
extraAttributes: Record<string, string> = {},
// 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[] = [];
Expand Down
6 changes: 6 additions & 0 deletions packages/host/app/lib/hydratable-entry-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -71,6 +74,7 @@ class _HydratableEntryComponent {
readonly errorDoc: ErrorEntry | undefined,
readonly mode: HydrationMode,
readonly overlays: boolean,
readonly displayContainer: boolean,
) {}
}

Expand All @@ -87,6 +91,7 @@ setComponentTemplate(
@errorDoc={{this.errorDoc}}
@mode={{this.mode}}
@overlays={{this.overlays}}
@displayContainer={{this.displayContainer}}
...attributes
/>`,
{
Expand Down Expand Up @@ -133,5 +138,6 @@ export function hydratableEntryComponent(
args.errorDoc,
args.mode,
args.overlays,
args.displayContainer,
) as unknown as EntryComponent;
}
25 changes: 24 additions & 1 deletion packages/host/app/resources/renderable-search-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,6 +105,7 @@ export class RenderableSearchEntry {
private fallbackFormat: PrerenderedHtmlFormat,
private mode: HydrationMode,
private overlays: boolean,
private displayContainer: boolean,
) {}

get id(): string {
Expand Down Expand Up @@ -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,
Expand All @@ -214,6 +230,7 @@ export class RenderableSearchEntry {
errorDoc: this.errorDoc,
mode: this.mode,
overlays: this.overlays,
displayContainer: this.displayContainer,
});
}
return this.#component;
Expand Down Expand Up @@ -242,6 +259,7 @@ export class RenderableSearchEntries {
private resource: ReturnType<typeof getSearchEntriesResource>,
private getMode: () => HydrationMode,
private getOverlays: () => boolean,
private getDisplayContainer: () => boolean,
) {}

private get fallbackRenderType(): ResolvedCodeRef | undefined {
Expand All @@ -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,
]);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -329,5 +351,6 @@ export function getRenderableSearchEntries(
getSearchEntriesResource(owner, getQuery, opts),
getMode,
getOverlays,
getDisplayContainer,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<template>
<CardSearchContext as |context|>
<context.searchResultsComponent
@query={{query}}
@mode='hover'
@displayContainer={{false}}
/>
</CardSearchContext>
</template>,
);
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
Expand Down
47 changes: 47 additions & 0 deletions packages/host/tests/integration/components/html-component-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div class='card'><span>Body</span></div>\n\n `,
);

await render(<template><Inert data-test-root /></template>);

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(
`<div class='card ring'><span>Body</span></div>`,
{},
(root) => root.classList.replace('ring', 'plain'),
);

await render(<template><Inert data-test-root /></template>);

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(`<span>One</span><span>Two</span>`, {}, () => {
calls++;
});

await render(
<template>
<div data-test-wrap><Inert /></div>
</template>,
);

assert.strictEqual(calls, 0, 'the hook never ran');
assert.dom('[data-test-wrap]').hasText('OneTwo', 'the HTML still renders');
});
});
Loading
Loading