Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/remove-legacy-template-system-css.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coveo/atomic': patch
---

Removed the legacy result template-system stylesheet and migrated the Insight, Recommendation, and result placeholder components to the sanitized template system. Result sections now receive their layout classes consistently across every interface, so per-section styling is applied by the shared result-section components instead of the removed monolithic stylesheet.
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,52 @@ describe('atomic-result-placeholder', () => {
expect(resultRoot?.classList.contains(`image-${imageSize}`)).toBe(true);
});
});

describe('section layout classes', () => {
it('should apply the with-sections class to section elements', async () => {
const {visual, badges, title, excerpt, bottomMetadata} = await renderComponent();

for (const section of [visual, badges, title, excerpt, bottomMetadata]) {
expect(section?.classList.contains('with-sections')).toBe(true);
}
});

it.each<{display: ItemDisplayLayout}>([
{display: 'list'},
{display: 'grid'},
{display: 'table'},
])(
'should apply the display class to section elements when display is $display',
async ({display}) => {
const {visual} = await renderComponent({display});
const expectedClass = display === 'list' ? 'display-list' : `display-${display}`;
expect(visual?.classList.contains(expectedClass)).toBe(true);
}
);

it.each<{density: ItemDisplayDensity}>([
{density: 'comfortable'},
{density: 'normal'},
{density: 'compact'},
])(
'should apply the density class to section elements when density is $density',
async ({density}) => {
const {title} = await renderComponent({density});
expect(title?.classList.contains(`density-${density}`)).toBe(true);
}
);

it.each<{imageSize: ItemDisplayImageSize}>([
{imageSize: 'large'},
{imageSize: 'small'},
{imageSize: 'icon'},
{imageSize: 'none'},
])(
'should apply the image class to section elements when imageSize is $imageSize',
async ({imageSize}) => {
const {visual} = await renderComponent({imageSize});
expect(visual?.classList.contains(`image-${imageSize}`)).toBe(true);
}
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const placeholderClasses = 'block bg-neutral w-full h-full rounded';
@withTailwindStyles
export class AtomicResultPlaceholder extends LitElement {
static styles = css`
@reference '../../common/template-system/legacy-template-system.css';
@reference '../../common/template-system/template-system.css';

:host {
@apply atomic-template-system;
Expand All @@ -37,6 +37,16 @@ export class AtomicResultPlaceholder extends LitElement {
}
}

> atomic-result-section-excerpt {
@apply set-font-size-base;

@media (width >= theme(--breakpoint-desktop)) {
&.density-comfortable {
@apply set-font-size-lg;
}
}
}

.badge {
width: 14rem;
}
Expand Down Expand Up @@ -93,35 +103,43 @@ export class AtomicResultPlaceholder extends LitElement {
}

render(): TemplateResult {
const displayClasses = getItemDisplayClasses(this.display, this.density, this.imageSize);

const classes = [
'result-root',
'placeholder',
'with-sections',
'animate-pulse',
...getItemDisplayClasses(this.display, this.density, this.imageSize),
...displayClasses,
]
.join(' ')
.trim();

// The sanitized template system lays each section out via layout classes
// applied to the section element itself. The placeholder renders its
// sections directly (without the item layout controller), so mirror those
// classes here.
const sectionClasses = ['with-sections', ...displayClasses].join(' ').trim();

return html`
<div class=${classes}>
<atomic-result-section-visual>
<atomic-result-section-visual class=${sectionClasses}>
<div class=${placeholderClasses}></div>
</atomic-result-section-visual>
<atomic-result-section-badges>
<atomic-result-section-badges class=${sectionClasses}>
<div class="badge ${placeholderClasses}"></div>
</atomic-result-section-badges>
<atomic-result-section-actions>
<atomic-result-section-actions class=${sectionClasses}>
<div class="action ${placeholderClasses}"></div>
</atomic-result-section-actions>
<atomic-result-section-title>
<atomic-result-section-title class=${sectionClasses}>
<div class="title ${placeholderClasses}"></div>
</atomic-result-section-title>
<atomic-result-section-excerpt>
<atomic-result-section-excerpt class=${sectionClasses}>
${this.renderExcerptLine('100%')} ${this.renderExcerptLine('95%')}
${this.renderExcerptLine('98%')}
</atomic-result-section-excerpt>
<atomic-result-section-bottom-metadata>
<atomic-result-section-bottom-metadata class=${sectionClasses}>
<div class="fields-placeholder">
${Array.from(
{length: 4},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,91 @@ describe('ItemLayoutController', () => {
expect(element2.classList.contains('display-list')).toBe(true);
});

it('should apply layout classes to shared section elements even when their tag does not match the element prefix', () => {
mockOptions.elementPrefix = 'atomic-recs-result';
controller = new ItemLayoutController(mockElement, mockOptions);
controller.hostConnected();

const mockRoot = document.createElement('div');
mockRoot.className = 'result-root';
const section = document.createElement('atomic-result-section-visual');
const child = document.createElement('atomic-recs-result-link');
mockRoot.appendChild(section);
mockRoot.appendChild(child);

vi.spyOn(mockElement.shadowRoot!, 'querySelector').mockReturnValue(mockRoot);

controller.hostUpdated();

expect(section.classList.contains('display-list')).toBe(true);
expect(section.classList.contains('density-normal')).toBe(true);
expect(section.classList.contains('image-icon')).toBe(true);
// Prefixed children keep being classed when classesOnly is not set.
expect(child.classList.contains('display-list')).toBe(true);
});

it('should not propagate item-level classes to shared section elements', () => {
mockOptions.elementPrefix = 'atomic-recs-result';
controller = new ItemLayoutController(mockElement, mockOptions);
controller.hostConnected();

const mockRoot = document.createElement('div');
mockRoot.className = 'result-root';
const section = document.createElement('atomic-result-section-children');
const child = document.createElement('atomic-recs-result-link');
mockRoot.appendChild(section);
mockRoot.appendChild(child);

vi.spyOn(mockElement.shadowRoot!, 'querySelector').mockReturnValue(mockRoot);

controller.hostUpdated();

// Sections receive only the layout classes.
expect(section.classList.contains('display-list')).toBe(true);
expect(section.classList.contains('custom-class')).toBe(false);
expect(section.classList.contains('extra-class')).toBe(false);
// Prefixed children still receive the combined (layout + item) classes.
expect(child.classList.contains('display-list')).toBe(true);
expect(child.classList.contains('custom-class')).toBe(true);
});

describe('when classesOnly is true', () => {
beforeEach(() => {
mockOptions.elementPrefix = 'atomic-insight-result';
mockOptions.classesOnly = true;
controller = new ItemLayoutController(mockElement, mockOptions);
controller.hostConnected();
});

it('should still apply layout classes to shared section elements', () => {
const mockRoot = document.createElement('div');
mockRoot.className = 'result-root';
const section = document.createElement('atomic-result-section-title');
mockRoot.appendChild(section);

vi.spyOn(mockElement.shadowRoot!, 'querySelector').mockReturnValue(mockRoot);

controller.hostUpdated();

expect(section.classList.contains('display-list')).toBe(true);
expect(section.classList.contains('density-normal')).toBe(true);
expect(section.classList.contains('image-icon')).toBe(true);
});

it('should not apply layout classes to prefixed child elements', () => {
const mockRoot = document.createElement('div');
mockRoot.className = 'result-root';
const child = document.createElement('atomic-insight-result-children');
mockRoot.appendChild(child);

vi.spyOn(mockElement.shadowRoot!, 'querySelector').mockReturnValue(mockRoot);

controller.hostUpdated();

expect(child.classList.length).toBe(0);
});
});

it('should use MutationObserver when custom render function is present', () => {
const mockRenderFunction = vi.fn();
mockOptions.renderingFunction = vi.fn().mockReturnValue(mockRenderFunction);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {LitElement, ReactiveController, ReactiveControllerHost} from 'lit';
import type {ItemRenderingFunction} from '@/src/components/common/item-list/item-list-common';
import type {AnyItem} from '@/src/components/common/item-list/unfolded-item';
import {isResultSectionNode} from './item-layout-sections';
import type {
ItemDisplayDensity,
ItemDisplayImageSize,
Expand All @@ -25,9 +26,12 @@ export interface ItemLayoutOptions {
layoutConfig: () => LayoutDisplayConfig;
itemClasses: () => string;
/**
* When true, the controller only computes classes without automatically
* applying them to child elements during hostUpdated.
* Useful for components that don't need class propagation to child elements.
* When true, layout classes are not propagated to the host's prefixed child
* elements (e.g. `atomic-insight-result-*`) during `hostUpdated`.
*
* Shared item-section elements (e.g. `atomic-result-section-*`) are always
* classed regardless of this flag, since the sanitized template system relies
* on those classes being present on the section element itself to lay it out.
*/
classesOnly?: boolean;
}
Expand All @@ -54,9 +58,7 @@ export class ItemLayoutController implements ReactiveController {
}

hostUpdated(): void {
if (!this.options.classesOnly) {
this.applyLayoutClasses();
}
this.applyLayoutClasses();
}

/**
Expand Down Expand Up @@ -142,11 +144,28 @@ export class ItemLayoutController implements ReactiveController {
return;
}

// Shared item-section elements (e.g. `atomic-result-section-*`) only receive
// the layout classes (display/density/image size/`with-sections`) so they can
// lay themselves out under the sanitized template system, regardless of the
// host's element prefix or `classesOnly` flag.
//
// Item-level classes such as `child-result`/`last-child` or
// consumer-provided classes must stay on the item root only. Propagating
// them to sections makes shared section rules (e.g.
// `.child-result:not(.last-child)`) match the section element itself and add
// spurious spacing.
const config = this.getLayout();
const sectionClasses = config ? getItemLayoutClasses(config) : [];

const elements = root.querySelectorAll('*');
elements.forEach((element) => {
const tagName = element.tagName.toLowerCase();
if (tagName.startsWith(`${this.options.elementPrefix}-`)) {
const isPrefixedChild =
!this.options.classesOnly && tagName.startsWith(`${this.options.elementPrefix}-`);
if (isPrefixedChild) {
element.classList.add(...classes);
} else if (isResultSectionNode(element) && sectionClasses.length > 0) {
element.classList.add(...sectionClasses);
}
});
}
Expand Down
Loading
Loading