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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import '../../../../global/global.pcss';
@import '../../../global/global.pcss';

:host {
atomic-tab-bar::part(popover-button) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {buildTab, buildTabManager, type TabManagerState} from '@coveo/headless';
import {html} from 'lit';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {page} from 'vitest/browser';
import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture';
import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller';
import type {AtomicTabManager} from './atomic-tab-manager';
import './atomic-tab-manager';

vi.mock('@coveo/headless', {spy: true});

describe('atomic-tab-manager', () => {
const locators = {
tabArea: page.getByLabel('tab-area'),
parts: (element: AtomicTabManager) => {
const qs = (part: string) =>
element.shadowRoot?.querySelector(`[part="${part}"]`);
return {
tabArea: qs('tab-area'),
};
},
};

const renderTabManager = async ({
tabManagerState,
slottedContent,
clearFiltersOnTabChange = false,
}: {
tabManagerState?: Partial<TabManagerState>;
slottedContent?: ReturnType<typeof html>;
clearFiltersOnTabChange?: boolean;
} = {}) => {
const fakeTabManager = buildFakeTabManager({
activeTab: 'all',
...tabManagerState,
});

vi.mocked(buildTabManager).mockReturnValue(fakeTabManager);

vi.mocked(buildTab).mockImplementation((_engine, options) => {
return {
state: {
isActive: options?.options?.id === fakeTabManager.state.activeTab,
},
select: vi.fn(),
subscribe: vi.fn(),
};
});

const {element} = await renderInAtomicSearchInterface<AtomicTabManager>({
template: html`
<atomic-tab-manager ?clear-filters-on-tab-change=${clearFiltersOnTabChange}>
${
slottedContent ??
html`
<atomic-tab label="All" name="all"></atomic-tab>
<atomic-tab label="Documentation" name="documentation"></atomic-tab>
<atomic-tab label="Articles" name="articles"></atomic-tab>
`
}
</atomic-tab-manager>
`,
selector: 'atomic-tab-manager',
});

return {element, fakeTabManager};
};

describe('when rendering with valid props', () => {
it('should render successfully', async () => {
const {element} = await renderTabManager();
expect(element).toBeInTheDocument();
});

it('should render the tab area', async () => {
await renderTabManager();
await expect.element(locators.tabArea).toBeInTheDocument();
});

it('should have shadow DOM parts', async () => {
const {element} = await renderTabManager();
const parts = locators.parts(element);
expect(parts.tabArea).toBeDefined();
});
});

describe('when initializing', () => {
it('should call buildTabManager with the engine', async () => {
const {element} = await renderTabManager();

expect(buildTabManager).toHaveBeenCalledWith(element.bindings.engine);
});

it('should call buildTab for each atomic-tab child', async () => {
await renderTabManager();

expect(buildTab).toHaveBeenCalledTimes(3);
});

it('should create tab controllers with correct options', async () => {
const {element} = await renderTabManager();

expect(buildTab).toHaveBeenCalledWith(element.bindings.engine, {
options: {
expression: '',
id: 'all',
clearFiltersOnTabChange: false,
},
});
});

it('should pass clearFiltersOnTabChange prop to tab controllers', async () => {
const {element} = await renderTabManager({clearFiltersOnTabChange: true});

expect(buildTab).toHaveBeenCalledWith(element.bindings.engine, {
options: {
expression: '',
id: 'all',
clearFiltersOnTabChange: true,
},
});
});
});

describe('when rendering tabs', () => {
it('should localize the label using bindings.i18n.t', async () => {
const {element} = await renderTabManager();
const i18nSpy = vi.spyOn(element.bindings.i18n, 't');

// Trigger a re-render
element.requestUpdate();
await element.updateComplete;

expect(i18nSpy).toHaveBeenCalledWith('All', {
defaultValue: 'All',
});
});
});

describe('when no atomic-tab children exist', () => {
let _consoleErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
_consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
});

it('should set error when no atomic-tab children', async () => {
const {element} = await renderTabManager({
slottedContent: html``,
});

expect(element.error).toBeDefined();
expect(element.error.message).toBe(
'The "atomic-tab-manager" element requires at least one "atomic-tab" child.'
);
});
});

describe('when atomic-tab child is missing name attribute', () => {
let _consoleErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
_consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
});

it('should set error when atomic-tab is missing name', async () => {
const {element} = await renderTabManager({
slottedContent: html`
<atomic-tab label="Test"></atomic-tab>
`,
});

expect(element.error).toBeDefined();
expect(element.error.message).toBe(
'The "name" attribute must be defined on all "atomic-tab" children.'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {
buildTab,
buildTabManager,
type Tab,
type TabManager,
type TabManagerState,
} from '@coveo/headless';
import {type CSSResultGroup, css, html, LitElement} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import type {Bindings} from '@/src/components/search/atomic-search-interface/atomic-search-interface';
import {bindStateToController} from '@/src/decorators/bind-state';
import {bindingGuard} from '@/src/decorators/binding-guard';
import {bindings} from '@/src/decorators/bindings';
import {errorGuard} from '@/src/decorators/error-guard';
import type {InitializableComponent} from '@/src/decorators/types';
import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles';

interface TabInfo {
label: string;
name: string;
tabController: Tab;
}

/**
* The `atomic-tab-manager` component manages a collection of tabs,
* allowing users to switch between them. Each child `atomic-tab` represents an
* individual tab within the manager.
*
* @part button-container - The container for the tab button.
* @part button-container-active - The container for the active tab button.
* @part tab-button - The tab button.
* @part tab-button-active - The container for the active tab button.
* @part dropdown-area - The dropdown area.
* @part tab-area - The tab area.
* @part popover-button - The "More" button shown when the tabs are collapsed.
* @part value-label - The label shown on the "More" button.
* @part arrow-icon - The down chevron icon shown on the "More" button.
* @part overflow-tabs - The list of tabs shown when the "More" button is clicked.
* @part popover-tab - The individual tab buttons shown when the "More" button is clicked.
* @part backdrop - The backdrop shown when the "More" button is clicked.
* @slot (default) - The `atomic-tab` elements that represent the tabs.
*/
@customElement('atomic-tab-manager')
@bindings()
@withTailwindStyles
export class AtomicTabManager
extends LitElement
implements InitializableComponent<Bindings>
{
static styles: CSSResultGroup = css`
atomic-tab-bar::part(popover-button) {
margin: 0;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-bottom: 0.25rem;
text-align: left;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 400;
color: black;
}

@media (min-width: 640px) {
atomic-tab-bar::part(popover-button) {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}

atomic-tab-bar::part(value-label) {
font-weight: 400;
}

::part(popover-tab) {
font-weight: 400;
}
`;

public bindings!: Bindings;
public tabManager!: TabManager;

@bindStateToController('tabManager')
@state()
private tabManagerState!: TabManagerState;

@state() public error!: Error;

private tabs: TabInfo[] = [];

/**
* Whether to clear the filters when the active tab changes.
*/
@property({type: Boolean, attribute: 'clear-filters-on-tab-change'})
clearFiltersOnTabChange = false;

public initialize() {
this.tabManager = buildTabManager(this.bindings.engine);

const tabElements = Array.from(this.querySelectorAll('atomic-tab'));

if (tabElements.length === 0) {
this.error = new Error(
'The "atomic-tab-manager" element requires at least one "atomic-tab" child.'
);
return;
}

tabElements.forEach((tabElement) => {
if (!tabElement.name) {
this.error = new Error(
'The "name" attribute must be defined on all "atomic-tab" children.'
);
return;
}
const tabController = buildTab(this.bindings.engine, {
options: {
expression: tabElement.expression,
id: tabElement.name,
clearFiltersOnTabChange: this.clearFiltersOnTabChange,
},
});

this.tabs.push({
label: tabElement.label,
name: tabElement.name,
tabController,
});
});
}

@bindingGuard()
@errorGuard()
render() {
return html`
<atomic-tab-bar>
<div
role="list"
aria-label="tab-area"
part="tab-area"
class="border-neutral mb-2 flex w-full flex-row border-b"
>
${this.tabs.map((tab) => {
const isActive = this.tabManagerState.activeTab === tab.name;
return html`
<atomic-tab-button
.active=${isActive}
.label=${this.bindings.i18n.t(tab.label, {
defaultValue: tab.label,
})}
.select=${() => {
if (!tab.tabController.state.isActive) {
tab.tabController.select();
}
}}
></atomic-tab-button>
`;
})}
</div>
</atomic-tab-bar>
`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {Component, h, Element, State, Prop, Host} from '@stencil/core';
import {
BindStateToController,
InitializeBindings,
} from '../../../../utils/initialization-utils';
import {Bindings} from '../../atomic-search-interface/atomic-search-interface';
} from '../../../utils/initialization-utils';
import {Bindings} from '../atomic-search-interface/atomic-search-interface';

/**
* The `atomic-tab-manager` component manages a collection of tabs,
Expand Down
1 change: 1 addition & 0 deletions packages/atomic/src/components/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ export {AtomicSearchLayout} from './atomic-search-layout/atomic-search-layout.js
export {AtomicSegmentedFacetScrollable} from './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js';
export {AtomicSortDropdown} from './atomic-sort-dropdown/atomic-sort-dropdown.js';
export {AtomicTab} from './atomic-tab/atomic-tab.js';
export {AtomicTabManager} from './atomic-tab-manager/atomic-tab-manager.js';
export {AtomicText} from './atomic-text/atomic-text.js';
2 changes: 2 additions & 0 deletions packages/atomic/src/components/search/lazy-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export default {
'atomic-sort-dropdown': async () =>
await import('./atomic-sort-dropdown/atomic-sort-dropdown.js'),
'atomic-tab': async () => await import('./atomic-tab/atomic-tab.js'),
'atomic-tab-manager': async () =>
await import('./atomic-tab-manager/atomic-tab-manager.js'),
'atomic-text': async () => await import('./atomic-text/atomic-text.js'),
} as Record<string, () => Promise<unknown>>;

Expand Down
Loading