diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index a30a97a7dd2..51d86c60afa 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -3786,6 +3786,22 @@ export namespace Components { } interface TabBar { } + interface TabButton { + /** + * Whether the tab button is active. + */ + "active": boolean; + /** + * The label to display on the tab button. + */ + "label": string; + /** + * Click handler for the tab button. + */ + "select": () => void; + } + interface TabManagerBar { + } interface TabPopover { "setButtonVisibility": (isVisible: boolean) => Promise; "togglePopover": () => Promise; @@ -6040,6 +6056,18 @@ declare global { prototype: HTMLTabBarElement; new (): HTMLTabBarElement; }; + interface HTMLTabButtonElement extends Components.TabButton, HTMLStencilElement { + } + var HTMLTabButtonElement: { + prototype: HTMLTabButtonElement; + new (): HTMLTabButtonElement; + }; + interface HTMLTabManagerBarElement extends Components.TabManagerBar, HTMLStencilElement { + } + var HTMLTabManagerBarElement: { + prototype: HTMLTabManagerBarElement; + new (): HTMLTabManagerBarElement; + }; interface HTMLTabPopoverElement extends Components.TabPopover, HTMLStencilElement { } var HTMLTabPopoverElement: { @@ -6244,6 +6272,8 @@ declare global { "atomic-timeframe": HTMLAtomicTimeframeElement; "atomic-timeframe-facet": HTMLAtomicTimeframeFacetElement; "tab-bar": HTMLTabBarElement; + "tab-button": HTMLTabButtonElement; + "tab-manager-bar": HTMLTabManagerBarElement; "tab-popover": HTMLTabPopoverElement; } } @@ -9833,6 +9863,22 @@ declare namespace LocalJSX { } interface TabBar { } + interface TabButton { + /** + * Whether the tab button is active. + */ + "active"?: boolean; + /** + * The label to display on the tab button. + */ + "label": string; + /** + * Click handler for the tab button. + */ + "select": () => void; + } + interface TabManagerBar { + } interface TabPopover { } interface IntrinsicElements { @@ -10033,6 +10079,8 @@ declare namespace LocalJSX { "atomic-timeframe": AtomicTimeframe; "atomic-timeframe-facet": AtomicTimeframeFacet; "tab-bar": TabBar; + "tab-button": TabButton; + "tab-manager-bar": TabManagerBar; "tab-popover": TabPopover; } } @@ -10917,6 +10965,8 @@ declare module "@stencil/core" { */ "atomic-timeframe-facet": LocalJSX.AtomicTimeframeFacet & JSXBase.HTMLAttributes; "tab-bar": LocalJSX.TabBar & JSXBase.HTMLAttributes; + "tab-button": LocalJSX.TabButton & JSXBase.HTMLAttributes; + "tab-manager-bar": LocalJSX.TabManagerBar & JSXBase.HTMLAttributes; "tab-popover": LocalJSX.TabPopover & JSXBase.HTMLAttributes; } } diff --git a/packages/atomic/src/components/common/tab-manager/tab-button.tsx b/packages/atomic/src/components/common/tab-manager/tab-button.tsx index 04e8c64adaf..23d7847852c 100644 --- a/packages/atomic/src/components/common/tab-manager/tab-button.tsx +++ b/packages/atomic/src/components/common/tab-manager/tab-button.tsx @@ -1,31 +1,54 @@ -import {FunctionalComponent, h} from '@stencil/core'; -import {Button} from '../button'; +import {Component, Host, Prop, h} from '@stencil/core'; -export interface TabButtonProps { - label: string | undefined; - handleClick: () => void; - isActive: boolean; -} +/** + * @internal + */ +@Component({ + tag: 'tab-button', +}) +export class TabButton { + /** + * The label to display on the tab button. + */ + @Prop() label!: string; + + /** + * Whether the tab button is active. + */ + @Prop() active: boolean = false; + + /** + * Click handler for the tab button. + */ + @Prop() select!: () => void; -export const TabButton: FunctionalComponent = (props) => { - const activeTabClass = props.isActive - ? "relative after:content-[''] after:block after:w-full after:h-1 after:absolute after:-bottom-0.5 after:bg-primary after:rounded" - : ''; - const activeTabTextClass = props.isActive ? '' : 'text-neutral-dark'; - return ( -
  • - -
  • - ); -}; + private get activeTabClass() { + return this.active + ? 'relative after:block after:w-full after:h-1 after:absolute after:-bottom-0.5 after:bg-primary after:rounded' + : ''; + } + + private get activeTabTextClass() { + return this.active ? '' : 'text-neutral-dark'; + } + + render() { + return ( + + + + ); + } +} diff --git a/packages/atomic/src/components/common/tab-manager/tab-dropdown-option.tsx b/packages/atomic/src/components/common/tab-manager/tab-dropdown-option.tsx deleted file mode 100644 index c42f8d6b151..00000000000 --- a/packages/atomic/src/components/common/tab-manager/tab-dropdown-option.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {FunctionalComponent, h} from '@stencil/core'; - -interface TabDropdownOptionProps { - value: string; - label: string; - isSelected: boolean; -} - -export const TabDropdownOption: FunctionalComponent = ( - props -) => { - return ( - - ); -}; diff --git a/packages/atomic/src/components/common/tab-manager/tab-dropdown.tsx b/packages/atomic/src/components/common/tab-manager/tab-dropdown.tsx deleted file mode 100644 index a87837d0fcd..00000000000 --- a/packages/atomic/src/components/common/tab-manager/tab-dropdown.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import {FunctionalComponent, h} from '@stencil/core'; -import TabsIcon from '../../../images/arrow-bottom-rounded.svg'; - -export interface TabDropdownProps { - tabs: Array<{name: string; label: string}>; - activeTab: string; - onTabChange: (tabName: string) => void; - class?: string; -} - -export const TabDropdown: FunctionalComponent = ( - props, - children -) => { - return ( -
    - -
    - -
    -
    - ); -}; diff --git a/packages/atomic/src/components/common/tab-manager/tab-manager-bar.pcss b/packages/atomic/src/components/common/tab-manager/tab-manager-bar.pcss new file mode 100644 index 00000000000..c0a6d5ffd67 --- /dev/null +++ b/packages/atomic/src/components/common/tab-manager/tab-manager-bar.pcss @@ -0,0 +1,23 @@ +@import '../../../global/global.pcss'; + +:host { + white-space: nowrap; + width: 100%; + overflow-x: visible; + display: flex; + position: relative; + + button { + @apply text-left; + } + + tab-popover::part(popover-button) { + @apply text-xl; + @apply sm:px-6; + @apply px-2; + @apply pb-1; + @apply font-normal; + @apply m-0; + @apply text-black; + } +} diff --git a/packages/atomic/src/components/common/tab-manager/tab-manager-bar.tsx b/packages/atomic/src/components/common/tab-manager/tab-manager-bar.tsx new file mode 100644 index 00000000000..98f655b510b --- /dev/null +++ b/packages/atomic/src/components/common/tab-manager/tab-manager-bar.tsx @@ -0,0 +1,201 @@ +import {h, Component, Element, Host, State} from '@stencil/core'; +import {Button} from '../button'; +import {TabCommonElement} from '../tabs/tab-common'; + +/** + * @internal + */ +@Component({ + tag: 'tab-manager-bar', + shadow: true, + styleUrl: 'tab-manager-bar.pcss', +}) +export class TabManagerBar { + @Element() private host!: HTMLElement; + + @State() + private popoverTabs: (typeof Button)[] = []; + + private resizeObserver: ResizeObserver | undefined; + + private get tabsFromSlot(): TabCommonElement[] { + const isTab = (tagName: string) => /tab-button$/i.test(tagName); + return Array.from(this.host.querySelectorAll('*')).filter( + (element) => isTab(element.tagName) + ); + } + + private get selectedTab() { + return this.tabsFromSlot.find((tab) => tab.active); + } + + private get slotContentWidth() { + return this.tabsFromSlot.reduce( + (total, el) => + total + + parseFloat(window.getComputedStyle(el).getPropertyValue('width')), + 0 + ); + } + + private get containerWidth() { + return parseFloat( + window.getComputedStyle(this.host).getPropertyValue('width') + ); + } + + private get isOverflow() { + return this.slotContentWidth > this.containerWidth; + } + + private get tabPopover() { + return this.host.shadowRoot?.querySelector('tab-popover'); + } + + private get popoverWidth() { + return this.tabPopover ? this.getElementWidth(this.tabPopover) : 0; + } + + private get overflowingTabs() { + const containerRelativeRightPosition = + this.host.getBoundingClientRect().right; + const selectedTabRelativeRightPosition = + this.selectedTab?.getBoundingClientRect().right; + + return this.tabsFromSlot.filter((element) => { + const isBeforeSelectedTab = selectedTabRelativeRightPosition + ? selectedTabRelativeRightPosition > + element.getBoundingClientRect().right + : false; + + const minimumWidthNeeded = isBeforeSelectedTab + ? this.popoverWidth + this.getElementWidth(this.selectedTab) + : this.popoverWidth; + + const rightPositionLimit = !this.isOverflow + ? containerRelativeRightPosition + : containerRelativeRightPosition - minimumWidthNeeded; + + return ( + element.getBoundingClientRect().right > rightPositionLimit && + !element.active + ); + }); + } + + private get displayedTabs() { + return this.tabsFromSlot.filter((el) => !this.overflowingTabs.includes(el)); + } + + private get lastDisplayedTab() { + const displayedTabs = this.displayedTabs; + return displayedTabs[displayedTabs.length - 1]; + } + + private get lastDisplayedTabRightPosition() { + return ( + this.lastDisplayedTab.getBoundingClientRect().right - + this.host.getBoundingClientRect().left + ); + } + + private updatePopoverPosition() { + this.tabPopover?.style.setProperty( + 'left', + `${this.displayedTabs.length ? this.lastDisplayedTabRightPosition : 0}px` + ); + } + + private getElementWidth = (element?: Element) => { + return element + ? parseFloat(window.getComputedStyle(element).getPropertyValue('width')) + : 0; + }; + + private hideElement = (el: HTMLElement) => { + el.style.visibility = 'hidden'; + el.ariaHidden = 'true'; + }; + + private showElement = (el: HTMLElement) => { + el.style.visibility = 'visible'; + el.ariaHidden = 'false'; + }; + + private updateTabVisibility = ( + tabs: TabCommonElement[], + isVisible: boolean + ) => { + const tabCount = this.tabsFromSlot.length; + + tabs.forEach((tab, index) => { + tab.style.setProperty( + 'order', + String(isVisible ? index + 1 : tabCount - tabs.length + index + 1) + ); + if (isVisible) { + this.showElement(tab); + } else { + this.hideElement(tab); + } + }); + }; + + private updatePopoverTabs = () => { + this.popoverTabs = this.overflowingTabs.map((tab) => ( + + )); + }; + + private setTabButtonMaxWidth = () => { + this.displayedTabs.forEach((tab) => { + tab.style.setProperty('max-width', `calc(100% - ${this.popoverWidth}px)`); + }); + }; + + private updateTabsDisplay = () => { + this.updateTabVisibility(this.overflowingTabs, false); + this.updateTabVisibility(this.displayedTabs, true); + this.setTabButtonMaxWidth(); + this.updatePopoverPosition(); + this.updatePopoverTabs(); + this.tabPopover?.setButtonVisibility(!!this.overflowingTabs.length); + }; + + public componentWillUpdate() { + this.updateTabsDisplay(); + } + + public componentDidLoad() { + this.resizeObserver = new ResizeObserver(() => { + this.updateTabsDisplay(); + }); + this.resizeObserver.observe(this.host); + } + + public disconnectedCallback() { + this.resizeObserver?.disconnect(); + } + + public render = () => { + return ( + + + {this.popoverTabs} + + ); + }; +} diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx index ae04dc547dd..39450c1f411 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx @@ -51,8 +51,9 @@ const meta: Meta = { args: { 'slots-default': ` - + + `, }, }; diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.pcss b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.pcss index 2b85db4da0d..7a0133e5e82 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.pcss +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.pcss @@ -1,14 +1 @@ @import '../../../../global/global.pcss'; - -.hide-tabs { - height: 0; - visibility: hidden; - overflow: hidden; - margin: 0; - padding: 0; -} - -select:hover + div, -select:focus-visible + div { - @apply text-primary-light; -} diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.tsx b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.tsx index d5c7375d411..6ad2221005b 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.tsx +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.tsx @@ -5,14 +5,11 @@ import { Tab, buildTab, } from '@coveo/headless'; -import {Component, h, Element, State, Prop} from '@stencil/core'; +import {Component, h, Element, State, Prop, Host} from '@stencil/core'; import { BindStateToController, InitializeBindings, } from '../../../../utils/initialization-utils'; -import {TabButton} from '../../../common/tab-manager/tab-button'; -import {TabDropdown} from '../../../common/tab-manager/tab-dropdown'; -import {TabDropdownOption} from '../../../common/tab-manager/tab-dropdown-option'; import {Bindings} from '../../atomic-search-interface/atomic-search-interface'; /** @@ -47,9 +44,6 @@ export class AtomicTabManager { @State() public error!: Error; - private tabAreaRef: HTMLUListElement | undefined; - private tabDropdownRef: HTMLDivElement | undefined; - public initialize() { this.tabManager = buildTabManager(this.bindings.engine); @@ -86,77 +80,30 @@ export class AtomicTabManager { }); } - componentDidLoad() { - const tabArea = this.tabAreaRef; - const tabDropdown = this.tabDropdownRef; - if (tabArea && tabDropdown) { - const resizeObserver = new ResizeObserver(() => { - const tabAreaWidth = tabArea.offsetWidth; - const tabsWidth = Array.from(tabArea.children).reduce( - (totalWidth, tab) => totalWidth + (tab as HTMLElement).offsetWidth, - 0 - ); - - if (tabAreaWidth < tabsWidth) { - tabArea.classList.add('hide-tabs'); - tabDropdown.classList.remove('hidden'); - } else { - tabArea.classList.remove('hide-tabs'); - tabDropdown.classList.add('hidden'); - } - }); - - resizeObserver.observe(tabArea); - - return () => resizeObserver.disconnect(); - } - } - render() { return ( -
    -
      (this.tabAreaRef = el as HTMLUListElement)} - role="list" - aria-label="tab-area" - part="tab-area" - class="tab-area mb-2 flex w-full flex-row border-b" - > - {this.tabs.map((tab) => ( - { - if (!tab.tabController.state.isActive) { - tab.tabController.select(); - } - }} - > - ))} -
    -
    (this.tabDropdownRef = el as HTMLDivElement)}> - { - const selectedTab = this.tabs.find( - (tab) => tab.name === (e as string) - ); - if (selectedTab) { - selectedTab.tabController.select(); - } - }} + + +
    {this.tabs.map((tab) => ( - + select={() => { + if (!tab.tabController.state.isActive) { + tab.tabController.select(); + } + }} + > ))} - -
    -
    +
    + + ); } } diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/atomic-tab-manager.e2e.ts b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/atomic-tab-manager.e2e.ts index 724d03cefd5..81298ca8cd6 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/atomic-tab-manager.e2e.ts +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/atomic-tab-manager.e2e.ts @@ -10,19 +10,20 @@ test.describe('AtomicTabManager', () => { await expect(tabManager.tabArea).toBeVisible(); }); - test('should not display tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeHidden(); + test('should not display tabs popover menu button', async ({tabManager}) => { + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); }); test('should display tab buttons for each atomic-tab elements', async ({ tabManager, }) => { - await expect(tabManager.tabDropdown).toBeHidden(); + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); await expect(tabManager.tabButtons()).toHaveText([ 'All', - 'Articles', 'Documentation', + 'Articles', + 'Parts and Accessories', ]); }); @@ -36,8 +37,10 @@ test.describe('AtomicTabManager', () => { await expect(tabManager.tabArea).toBeVisible(); }); - test('should not display tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeHidden(); + test('should not display tabs popover menu button', async ({ + tabManager, + }) => { + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); }); test.describe('should change other component visibility', async () => { @@ -89,12 +92,13 @@ test.describe('AtomicTabManager', () => { test('should display tab buttons for each atomic-tab elements', async ({ tabManager, }) => { - await expect(tabManager.tabDropdown).toBeHidden(); + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); await expect(tabManager.tabButtons()).toHaveText([ 'All', - 'Articles', 'Documentation', + 'Articles', + 'Parts and Accessories', ]); }); @@ -208,18 +212,34 @@ test.describe('AtomicTabManager', () => { await page.setViewportSize({width: 300, height: 500}); }); - test('should display tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeVisible(); + test('should display tabs popover menu button', async ({ + tabManager, + }) => { + await expect(tabManager.tabPopoverMenuButton).toBeVisible(); }); - test('should hide tabs area', async ({tabManager}) => { - await expect(tabManager.tabArea).toBeHidden(); + test('should move overflowed tabs to popover tabs', async ({ + tabManager, + }) => { + await tabManager.tabPopoverMenuButton.click(); + await expect(tabManager.popoverTabs()).toHaveText([ + 'Documentation', + 'Parts and Accessories', + ]); }); - test('should have the active tab selected in the dropdown', async ({ + test('should not have the active tab in the popover tabs', async ({ tabManager, }) => { - await expect(tabManager.tabDropdown).toHaveValue('article'); + await tabManager.tabPopoverMenuButton.click(); + const popoverTabs = tabManager.popoverTabs(); + await expect(popoverTabs).toHaveCount(2); + for (const tab of await popoverTabs.all()) { + await expect(tab).not.toHaveText('Articles'); + } + await expect( + tabManager.tabButtons().locator('visible=true') + ).toHaveText(['All', 'Articles']); }); }); }); @@ -235,31 +255,29 @@ test.describe('AtomicTabManager', () => { expect(accessibilityResults.violations).toEqual([]); }); - test('should not display tabs area', async ({tabManager}) => { - await expect(tabManager.tabArea).toBeHidden(); - }); - - test('should display tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeVisible(); + test('should display tabs popover menu button', async ({tabManager}) => { + await expect(tabManager.tabPopoverMenuButton).toBeVisible(); }); - test('should display tab dropdown options for each atomic-tab elements', async ({ + test('should move overflowed tabs to popover tabs', async ({ tabManager, }) => { - await expect(tabManager.tabDropdownOptions()).toHaveText([ - 'All', - 'Articles', + await tabManager.tabPopoverMenuButton.click(); + await expect(tabManager.popoverTabs()).toHaveText([ 'Documentation', + 'Articles', + 'Parts and Accessories', ]); }); - test.describe('when selecting a dropdown option', () => { + test.describe('when selecting a tab popover button', () => { test.beforeEach(async ({tabManager}) => { - await tabManager.tabDropdown.selectOption('article'); + await tabManager.tabPopoverMenuButton.click(); + await tabManager.popoverTabs('Articles').click(); }); test('should change active tab', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toHaveValue('article'); + await expect(tabManager.activeTab).toHaveText('Articles'); }); test.describe('should change other component visibility', async () => { @@ -311,9 +329,10 @@ test.describe('AtomicTabManager', () => { }); }); - test.describe('when selecting previous dropdown option', () => { + test.describe('when selecting another tab in popover buttons', () => { test.beforeEach(async ({tabManager}) => { - await tabManager.tabDropdown.selectOption('all'); + await tabManager.tabPopoverMenuButton.click(); + await tabManager.popoverTabs('Parts and Accessories').click(); }); test.describe('should change other component visibility', async () => { @@ -369,18 +388,21 @@ test.describe('AtomicTabManager', () => { await page.setViewportSize({width: 1000, height: 500}); }); - test('should hide tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeHidden(); - }); - - test('should display tabs area', async ({tabManager}) => { - await expect(tabManager.tabArea).toBeVisible(); + test('should hide tab popover menu button', async ({tabManager}) => { + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); }); - test('should have the active tab button selected', async ({ + test('should display tab buttons for each atomic-tab elements', async ({ tabManager, }) => { - await expect(tabManager.activeTab).toHaveText('Articles'); + await expect(tabManager.tabPopoverMenuButton).toBeHidden(); + + await expect(tabManager.tabButtons()).toHaveText([ + 'All', + 'Documentation', + 'Articles', + 'Parts and Accessories', + ]); }); }); }); diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/page-object.ts b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/page-object.ts index 48d25fb8733..86784273606 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/page-object.ts +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/page-object.ts @@ -6,8 +6,8 @@ export class TabManagerPageObject extends BasePageObject<'atomic-tab-manager'> { super(page, 'atomic-tab-manager'); } - get tabDropdown() { - return this.page.getByLabel('tab-dropdown-area').getByRole('combobox'); + get tabPopoverMenuButton() { + return this.page.getByLabel('Popover menu for more tabs'); } get tabArea() { @@ -90,10 +90,10 @@ export class TabManagerPageObject extends BasePageObject<'atomic-tab-manager'> { return value ? baseLocator.filter({hasText: value}) : baseLocator; } - tabDropdownOptions(value?: string) { + popoverTabs(value?: string) { const baseLocator = this.page - .getByLabel('tab-dropdown-area') - .getByRole('option'); + .locator('tab-popover') + .locator('button[part="popover-tab"]'); return value ? baseLocator.filter({hasText: value}) : baseLocator; }