diff --git a/projects/assets/src/translations/en/product.json b/projects/assets/src/translations/en/product.json index 7249e52b56c..90cbeb7d4fc 100644 --- a/projects/assets/src/translations/en/product.json +++ b/projects/assets/src/translations/en/product.json @@ -98,7 +98,8 @@ "deliveryTab": "Shipping", "SparePartsTabComponent": " Spare Parts" }, - "tabPanelContainerRegion": "Tab group with more product details" + "tabPanelContainerRegion": "Tab group with more product details", + "tabPanelContainerRegionGroup": "Group with more product details" }, "addToWishList": { "add": "Add to Wish List", diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index c6c9175b1dc..8d62ad5dab0 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -416,6 +416,13 @@ export interface FeatureTogglesInterface { */ a11yUseButtonsForBtnLinks?: boolean; + /** + * Enables the use of TabComponent in the PLP and PDP page to replace some functionality + * of the FacetListComponent and TabParagraphComponent to make then keyboard accessible + * and responsive in tab and accordion stles. + */ + a11yTabComponent?: boolean; + /** * `ProductImageZoomProductImagesComponent`, `ProductImageZoomThumbnailsComponent` - enable * arrow keys navigation for the carousel @@ -621,6 +628,7 @@ export const defaultFeatureToggles: Required = { a11yEmptyWishlistHeading: false, a11yScreenReaderBloatFix: false, a11yUseButtonsForBtnLinks: false, + a11yTabComponent: false, a11yCarouselArrowKeysNavigation: false, a11yNotificationsOnConsentChange: false, a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: false, diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product_details/product-details.core-e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product_details/product-details.core-e2e.cy.ts index 4de8cd8ab31..183ed6333cd 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product_details/product-details.core-e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/product_details/product-details.core-e2e.cy.ts @@ -14,12 +14,14 @@ context('Product details', { testIsolation: false }, () => { before(productDetails.configureDefaultProduct); productDetails.productDetailsTest(); + productDetails.verifyTabKeyboardNavigation(); }); describe('Apparel', () => { before(productDetails.configureApparelProduct); productDetails.apparelProductDetailsTest(); + productDetails.verifyTabKeyboardNavigation(); }); }); @@ -40,12 +42,14 @@ context( before(productDetails.configureDefaultProduct); productDetails.productDetailsTest(); + productDetails.verifyTabKeyboardNavigation(true); }); describe('Apparel', () => { before(productDetails.configureApparelProduct); productDetails.apparelProductDetailsTest(); + productDetails.verifyTabKeyboardNavigation(true); }); } ); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-details.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-details.ts index 77e74d6ae39..5f35f20666d 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/product-details.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/product-details.ts @@ -8,7 +8,7 @@ import { addProductToCart as addToCart } from './applied-promotions'; export const summaryContainer = `cx-product-summary`; export const infoContainer = `cx-product-intro`; -export const tabsContainer = 'cx-tab-paragraph-container'; +export const tabsContainer = 'cx-tab'; export const tabsHeaderList = `${tabsContainer} > div > button`; export const activeTabContainer = `${tabsContainer} .active .container`; export const shippingTabActive = `${tabsContainer} .active cx-paragraph`; @@ -59,8 +59,7 @@ export function verifyShowReviewsLink() { .click(); cy.get(`${tabsHeaderList}`) .contains(/reviews/i) - .should('be.focused') - .and('have.attr', 'aria-expanded', 'true'); + .should('be.focused'); } export function verifyTextInTabs() { @@ -86,7 +85,8 @@ export function verifyTextInTabs() { } export function verifyContentInReviewTab() { - cy.get(tabsHeaderList).eq(2).click(); + // Double click to close and open on accordian view. + cy.get(tabsHeaderList).eq(2).click().click(); cy.get(reviewList).should('have.length', 5); cy.get(writeAReviewButton).should('be.visible'); } @@ -132,6 +132,43 @@ export function verifyQuantityInCart() { cy.get(headerCartButton).should('contain', '5'); } +/** + * Verify arrow keys, HOME/END keys and SPACE key. + */ +export function verifyTabKeyboardNavigation(accordian = false) { + it('should navigate tab component with keyboard', () => { + cy.reload(); + cy.get('cx-tab button').eq(0).click(); + cy.focused().contains('Product Details').type('{downArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Specs').type('{rightArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Reviews').type('{leftArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Specs').type('{upArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Product Details').type('{upArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Shipping').type('{downArrow}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Product Details').type('{end}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Shipping').type('{home}'); + verifySpaceBarKeyForAccordian(); + cy.focused().contains('Product Details'); + + function verifySpaceBarKeyForAccordian() { + if (accordian) { + cy.get('cx-tab-panel').should('not.exist'); + cy.focused().type(' '); + cy.get('cx-tab-panel').should('exist'); + cy.focused().type(' '); + cy.get('cx-tab-panel').should('not.exist'); + } + } + }); +} + export function selectProductStyleVariant() { cy.get(`${variantStyleList} li img[alt="glacier"]`).first().click(); diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 3781d326eec..6cbe2089355 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -346,6 +346,7 @@ if (environment.cpq) { a11yEmptyWishlistHeading: true, a11yScreenReaderBloatFix: true, a11yUseButtonsForBtnLinks: true, + a11yTabComponent: true, a11yCarouselArrowKeysNavigation: true, a11yNotificationsOnConsentChange: true, a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: diff --git a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.html b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.html index 7f42abb87e1..5c620d04dad 100644 --- a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.html +++ b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.html @@ -1,5 +1,6 @@
+ + + + + + + +
diff --git a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.spec.ts b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.spec.ts index 707eee8305c..3ba1be3b38b 100644 --- a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.spec.ts +++ b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.spec.ts @@ -7,6 +7,7 @@ import { I18nTestingModule, WindowRef, } from '@spartacus/core'; +import { MockFeatureDirective } from '../../../shared/test/mock-feature-directive'; import { EMPTY, of } from 'rxjs'; import { CmsComponentData } from '../../../cms-structure/index'; import { OutletDirective } from '../../../cms-structure/outlet/index'; @@ -83,6 +84,7 @@ describe('TabParagraphContainerComponent', () => { TabParagraphContainerComponent, ComponentWrapperDirective, OutletDirective, + MockFeatureDirective, ], providers: [ WindowRef, diff --git a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.ts b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.ts index 756f999b612..14d7311e66f 100644 --- a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.ts +++ b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, QueryList, + TemplateRef, ViewChildren, } from '@angular/core'; import { @@ -17,10 +18,17 @@ import { CMSTabParagraphContainer, WindowRef, } from '@spartacus/core'; -import { combineLatest, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import { ComponentWrapperDirective } from '../../../cms-structure/page/component/component-wrapper.directive'; import { CmsComponentData } from '../../../cms-structure/page/model/index'; +import { BREAKPOINT } from '../../../layout/config/layout-config'; +import { Tab, TabConfig } from '../tab/tab.model'; + +const defaultTabConfig = { + openTabs: [0], + breakpoint: BREAKPOINT.md, +}; @Component({ selector: 'cx-tab-paragraph-container', @@ -28,14 +36,33 @@ import { CmsComponentData } from '../../../cms-structure/page/model/index'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class TabParagraphContainerComponent implements AfterViewInit, OnInit { + /** + * @deprecated This method will be removed. + */ activeTabNum = 0; + /** + * @deprecated This method will be removed. + */ ariaLabel: string; + /** + * @deprecated This method will be removed. + */ @ViewChildren(ComponentWrapperDirective) children!: QueryList; + @ViewChildren('tabRef') + tabRefs: QueryList>; + + /** + * @deprecated This method will be removed. + */ tabTitleParams: (Observable | null)[] = []; + tabConfig$: BehaviorSubject = new BehaviorSubject( + defaultTabConfig + ); + constructor( public componentData: CmsComponentData, protected cmsService: CmsService, @@ -71,10 +98,23 @@ export class TabParagraphContainerComponent implements AfterViewInit, OnInit { }) ) ) + ).pipe( + // Update tablist label with name from CMS + tap(() => { + this.tabConfig$.next({ + label: `${data?.uid}.tabPanelContainerRegionGroup`, + ...defaultTabConfig, + }); + }) ) ) ); + tabs$: Observable; + + /** + * @deprecated This method will be removed. + */ select(tabNum: number, event?: MouseEvent): void { this.activeTabNum = this.activeTabNum === tabNum ? -1 : tabNum; if (event && event?.target) { @@ -88,6 +128,9 @@ export class TabParagraphContainerComponent implements AfterViewInit, OnInit { } } + /** + * @deprecated This method will be removed. + */ ngOnInit(): void { this.activeTabNum = this.winRef?.nativeWindow?.history?.state?.activeTab ?? this.activeTabNum; @@ -99,12 +142,29 @@ export class TabParagraphContainerComponent implements AfterViewInit, OnInit { if (this.children.length > 0) { this.getTitleParams(this.children); } + + // Render the tabs after the templates have completed loading in the view. + this.tabs$ = combineLatest([this.components$, this.tabRefs.changes]).pipe( + map(([components, refs]) => + components.map((component, index) => ({ + headerKey: component.title, + content: refs.get(index), + id: index, + })) + ) + ); } + /** + * @deprecated This method will be removed. + */ tabCompLoaded(componentRef: any): void { this.tabTitleParams.push(componentRef.instance.tabTitleParam$); } + /** + * @deprecated This method will be removed. + */ protected getTitleParams(children: QueryList) { children.forEach((comp) => { this.tabTitleParams.push(comp['cmpRef']?.instance.tabTitleParam$ ?? null); diff --git a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.module.ts b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.module.ts index 04474b0ac86..7a3f2f2e07f 100644 --- a/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.module.ts +++ b/projects/storefrontlib/cms-components/content/tab-paragraph-container/tab-paragraph-container.module.ts @@ -6,13 +6,26 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { CmsConfig, I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { + CmsConfig, + FeaturesConfigModule, + I18nModule, + provideDefaultConfig, +} from '@spartacus/core'; import { OutletModule } from '../../../cms-structure/outlet/outlet.module'; import { PageComponentModule } from '../../../cms-structure/page/component/page-component.module'; +import { TabModule } from '../tab/tab.module'; import { TabParagraphContainerComponent } from './tab-paragraph-container.component'; @NgModule({ - imports: [CommonModule, PageComponentModule, OutletModule, I18nModule], + imports: [ + CommonModule, + PageComponentModule, + OutletModule, + I18nModule, + TabModule, + FeaturesConfigModule, + ], providers: [ provideDefaultConfig({ cmsComponents: { diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html new file mode 100644 index 00000000000..33e81bd180b --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html @@ -0,0 +1,10 @@ +
+ +
diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts new file mode 100644 index 00000000000..b64b8dd0e69 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.spec.ts @@ -0,0 +1,77 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Tab, TAB_MODE } from '../tab.model'; +import { TabPanelComponent } from './tab-panel.component'; + +const mockTab: Tab | any = { + id: 1, +}; + +@Component({ + template: `hello`, +}) +class MockComponent { + @ViewChild('templateRef') templateRef: TemplateRef; +} + +describe('TabPanelComponent', () => { + let component: TabPanelComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TabPanelComponent, MockComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TabPanelComponent); + component = fixture.componentInstance; + component.tab = mockTab; + fixture.detectChanges(); + }); + + it('should create an instance', () => { + expect(component).toBeTruthy(); + }); + + it('should have correct attribues when is open in TAB mode', () => { + const tabPanel = document.querySelector('div[role="tabpanel"]'); + expect(tabPanel?.getAttribute('id')).toEqual('section-1'); + expect(tabPanel?.getAttribute('tabindex')).toEqual('0'); + expect(tabPanel?.getAttribute('role')).toEqual('tabpanel'); + expect(tabPanel?.getAttribute('class')).toEqual('active'); + expect(tabPanel?.getAttribute('aria-labelledby')).toEqual('1'); + }); + + it('should have correct attribues when is open in ACCORDIAN mode', () => { + component.mode = TAB_MODE.ACCORDIAN; + fixture.detectChanges(); + + const tabPanel = document.querySelector('div[role="region"]'); + expect(tabPanel?.getAttribute('id')).toEqual('section-1'); + expect(tabPanel?.getAttribute('tabindex')).toEqual('0'); + expect(tabPanel?.getAttribute('role')).toEqual('region'); + expect(tabPanel?.getAttribute('class')).toEqual('active'); + expect(tabPanel?.getAttribute('aria-labelledby')).toEqual('1'); + }); + + it('should display template ref', () => { + const mockFixture = TestBed.createComponent(MockComponent); + mockFixture.detectChanges(); + const templateRef = mockFixture.componentInstance.templateRef; + + fixture = TestBed.createComponent(TabPanelComponent); + component = fixture.componentInstance; + component.tab = { + ...mockTab, + content: templateRef, + }; + fixture.detectChanges(); + + const el = document.querySelector('span[id="tempRef"]'); + expect(el?.innerHTML).toEqual('hello'); + }); +}); diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.ts b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.ts new file mode 100644 index 00000000000..a8d30adb007 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, Input } from '@angular/core'; +import { Tab, TAB_MODE } from '../tab.model'; + +@Component({ + selector: 'cx-tab-panel', + templateUrl: './tab-panel.component.html', +}) +export class TabPanelComponent { + TAB_MODE = TAB_MODE; + + /** + * Tab object to display content and set attributes to. + */ + @Input() tab: Tab; + + /** + * In which layout to set the component (ie. Tab or Accordian). + * Defaults to "Tab" mode. + */ + @Input() mode: TAB_MODE = TAB_MODE.TAB; +} diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.module.ts b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.module.ts new file mode 100644 index 00000000000..4a2666761bb --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.module.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { KeyboardFocusModule } from '../../../../layout/a11y/keyboard-focus/keyboard-focus.module'; +import { OutletModule } from '../../../../cms-structure/outlet/outlet.module'; +import { PageComponentModule } from '../../../../cms-structure/page/component/page-component.module'; +import { TabPanelComponent } from './tab-panel.component'; + +@NgModule({ + imports: [ + CommonModule, + PageComponentModule, + OutletModule, + I18nModule, + KeyboardFocusModule, + ], + declarations: [TabPanelComponent], + exports: [TabPanelComponent], +}) +export class TabPanelModule {} diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.html b/projects/storefrontlib/cms-components/content/tab/tab.component.html new file mode 100644 index 00000000000..a262e786fef --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.html @@ -0,0 +1,60 @@ + + +
+ + + + + + +
+ + + + + + + + + + + +
diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts b/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts new file mode 100644 index 00000000000..4b8b3ca56fc --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.spec.ts @@ -0,0 +1,298 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { I18nTestingModule } from '@spartacus/core'; +import { TabComponent } from './tab.component'; +import { TAB_MODE } from './tab.model'; + +describe('TabComponent', () => { + let component: TabComponent; + let fixture: ComponentFixture; + + const mockTabs = [ + { + headerKey: 'tab0', + header: 'tab 0', + id: 0, + }, + { + headerKey: 'tab1', + header: 'tab 1', + id: 1, + }, + { + headerKey: 'tab2', + header: 'tab 2', + id: 2, + }, + { + headerKey: 'tab3', + header: 'tab 3', + id: 3, + }, + ]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule], + declarations: [TabComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TabComponent); + component = fixture.componentInstance; + component.tabs = mockTabs; + }); + + describe('Tab Mode', () => { + beforeEach(() => { + component.config = { + label: 'test', + mode: TAB_MODE.TAB, + openTabs: [0], + }; + fixture.detectChanges(); + }); + + it('should display menu buttons for tabs', () => { + const tabEl = document.querySelector('div[class="tab"]'); + expect(tabEl?.role).toEqual('tablist'); + + const buttonEls = document.querySelectorAll('button[role="tab"]'); + expect(buttonEls.length).toEqual(4); + + const firstButton = buttonEls[0]; + expect(firstButton.getAttribute('id')).toEqual('0'); + expect(firstButton.getAttribute('class')).toEqual('tab-btn active'); + expect(firstButton.getAttribute('aria-selected')).toEqual('true'); + expect(firstButton.getAttribute('aria-expanded')).toEqual(null); + expect(firstButton.getAttribute('aria-controls')).toEqual('section-0'); + expect(firstButton.getAttribute('tabindex')).toEqual('0'); + + const secondButton = buttonEls[1]; + expect(secondButton.getAttribute('id')).toEqual('1'); + expect(secondButton.getAttribute('class')).toEqual('tab-btn'); + expect(secondButton.getAttribute('aria-selected')).toEqual('false'); + expect(secondButton.getAttribute('aria-expanded')).toEqual(null); + expect(secondButton.getAttribute('aria-controls')).toEqual('section-1'); + expect(secondButton.getAttribute('tabindex')).toEqual('-1'); + }); + + it('should navigate menu buttons with arrow keys', () => { + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowDown' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(true); + + component.handleKeydownEvent( + 2, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + }); + + it('should wrap navigation on menu buttons with arrow keys', () => { + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(3)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(3)).toEqual(true); + + component.handleKeydownEvent( + 3, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowDown' }) + ); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(3)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(3)).toEqual(true); + + component.handleKeydownEvent( + 3, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(3)).toEqual(false); + }); + + it('should navigate to last tab with END key', () => { + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(3)).toEqual(false); + + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowDown' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(3)).toEqual(false); + + component.handleKeydownEvent( + 1, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'End' }) + ); + + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(3)).toEqual(true); + }); + + it('should navigate to first tab with HOME key', () => { + component.handleKeydownEvent( + 0, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(component.isOpen(3)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + expect(component.isOpen(0)).toEqual(false); + + component.handleKeydownEvent( + 3, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'ArrowUp' }) + ); + + expect(component.isOpen(3)).toEqual(false); + expect(component.isOpen(2)).toEqual(true); + expect(component.isOpen(0)).toEqual(false); + + component.handleKeydownEvent( + 2, + component.tabs, + component.config.mode, + new KeyboardEvent('keydown', { key: 'Home' }) + ); + + expect(component.isOpen(3)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + expect(component.isOpen(0)).toEqual(true); + }); + }); + + describe('Accordian Mode', () => { + beforeEach(() => { + component.config = { + label: 'test', + mode: TAB_MODE.ACCORDIAN, + openTabs: [0], + }; + fixture.detectChanges(); + }); + + it('should display menu buttons for tabs', () => { + const accordianEl = document.querySelector('div[class="accordian"]'); + expect(accordianEl?.role).toEqual('presentation'); + const buttonEls = document.querySelectorAll('button[role="button"]'); + expect(buttonEls.length).toEqual(4); + + const firstButton = buttonEls[0]; + expect(firstButton.getAttribute('id')).toEqual('0'); + expect(firstButton.getAttribute('class')).toEqual('tab-btn active'); + expect(firstButton.getAttribute('aria-selected')).toEqual(null); + expect(firstButton.getAttribute('aria-expanded')).toEqual('true'); + expect(firstButton.getAttribute('aria-controls')).toEqual('section-0'); + expect(firstButton.getAttribute('tabindex')).toEqual('0'); + + const secondButton = buttonEls[1]; + expect(secondButton.getAttribute('id')).toEqual('1'); + expect(secondButton.getAttribute('class')).toEqual('tab-btn'); + expect(secondButton.getAttribute('aria-selected')).toEqual(null); + expect(secondButton.getAttribute('aria-expanded')).toEqual('false'); + expect(secondButton.getAttribute('aria-controls')).toEqual('section-1'); + expect(secondButton.getAttribute('tabindex')).toEqual('0'); + }); + + it('should toggle tabs correctly in accordian mode', () => { + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(false); + component.toggleTab(1); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(false); + component.toggleTab(2); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(true); + expect(component.isOpen(2)).toEqual(true); + component.toggleTab(1); + + expect(component.isOpen(0)).toEqual(true); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(true); + + component.toggleTab(0); + expect(component.isOpen(0)).toEqual(false); + expect(component.isOpen(1)).toEqual(false); + expect(component.isOpen(2)).toEqual(true); + }); + }); +}); diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.ts b/projects/storefrontlib/cms-components/content/tab/tab.component.ts new file mode 100644 index 00000000000..93dc75b5591 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.ts @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, + QueryList, + ViewChildren, +} from '@angular/core'; +import { BreakpointService } from '../../../layout/breakpoint'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Tab, TabConfig, TAB_MODE } from './tab.model'; +import { wrapIntoBounds } from './tab.utils'; +import { TranslationService } from '@spartacus/core'; + +@Component({ + selector: 'cx-tab', + templateUrl: './tab.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TabComponent implements OnInit { + @Input() tabs: Tab[] | any; + @Input() config: TabConfig | any; + + readonly TAB_MODE = TAB_MODE; + + openTabs$: BehaviorSubject; + mode$: Observable; + + @ViewChildren('tabHeader') tabHeaders: QueryList; + + constructor( + protected breakpointService: BreakpointService, + protected translationService: TranslationService + ) {} + + ngOnInit(): void { + this.openTabs$ = new BehaviorSubject(this.config?.openTabs ?? []); + this.mode$ = this.getMode(); + } + + /** + * Tab selection works differently depending on the given mode. + * + * Modes: + * - Tab: Closes all other tabs and opens the given tab. + * - Accordian: Toggles the given tab open or closed. + */ + select(tabNum: number, mode: TAB_MODE): void { + this.focus(tabNum); + + switch (mode) { + case TAB_MODE.TAB: + return this.openTabs$.next([tabNum]); + case TAB_MODE.ACCORDIAN: + return this.toggleTab(tabNum); + } + } + + /** + * Focuses the given tab according to the number. + */ + focus(tabNum: number): void { + this.tabHeaders.toArray()[tabNum].nativeElement.focus(); + } + + /** + * Calls select or focus methods depending on the tab mode. + */ + selectOrFocus(tabNum: number, mode: TAB_MODE, event: KeyboardEvent): void { + event.preventDefault(); + + switch (mode) { + case TAB_MODE.TAB: + return this.select(tabNum, mode); + case TAB_MODE.ACCORDIAN: + return this.focus(tabNum); + } + } + + /** + * Handles keydown events made on tabs. + * + * Keys: + * - ArrowUp / ArrowLeft: Select or focus the previous tab. + * - ArrowRight / ArrowDown: Select or focus the next tab. + * - Home: Select or focus the first tab. + * - End: Select or focus the last tab. + */ + handleKeydownEvent( + tabNum: number, + tabs: Tab[], + mode: TAB_MODE, + event: KeyboardEvent + ): void { + const FIRST_TAB = 0; + const LAST_TAB = tabs.length - 1; + const PREVIOUS_TAB = wrapIntoBounds(tabNum - 1, LAST_TAB); + const NEXT_TAB = wrapIntoBounds(tabNum + 1, LAST_TAB); + + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + return this.selectOrFocus(PREVIOUS_TAB, mode, event); + case 'ArrowRight': + case 'ArrowDown': + return this.selectOrFocus(NEXT_TAB, mode, event); + case 'Home': + return this.selectOrFocus(FIRST_TAB, mode, event); + case 'End': + return this.selectOrFocus(LAST_TAB, mode, event); + } + } + + /** + * Indicates whether a tab is open (in the open tabs array). + */ + isOpen(tabNum: number): boolean { + return ( + this.getOpenTabs().find((open: number) => open === tabNum) !== undefined + ); + } + + /** + * Inverts the state of the given tab between open and closed. + */ + toggleTab(tabNum: number): void { + const openTabs = this.getOpenTabs(); + const openTabIndex = openTabs.indexOf(tabNum); + + openTabIndex > -1 + ? openTabs.splice(openTabIndex, 1) + : openTabs.push(tabNum); + + this.openTabs$.next(openTabs); + } + + /** + * Returns index 0 if the tab is already open, + * no tabs are open and its the first tab (ie. 0), + * or in 'ACCORDIAN' mode. + * Otherwise returns -1. + */ + getTabIndex(tabNum: number, mode: TAB_MODE): number { + return this.isOpen(tabNum) || + (tabNum === 0 && this.getOpenTabs().length === 0) || + mode === 'ACCORDIAN' + ? 0 + : -1; + } + + getTitle(mode: TAB_MODE, index: number) { + const tab = this.tabs[index]; + + // Not required in Tab mode. + if (mode === TAB_MODE.TAB) { + return null; + } + + return ( + // Show expanded or collapsed. + (this.isOpen(index) ? 'Collapse' : 'Expand') + + ' ' + + // Show the translation key for header if available. + // Otherwise fallback to header string value. + (tab.headerKey + ? this.translationService.translate(tab.headerKey) + : tab.header) + ); + } + + protected getOpenTabs(): number[] { + return this.openTabs$.value; + } + + /** + * Returns the mode specified by the config. + * If unspecified mode, return 'TAB' or 'ACCORDIAN' modes responsively using the specified breakpoint in the config. + * If unspecified breakpoint, return 'TAB' mode by default. + */ + protected getMode(): Observable { + if (this.config.mode) { + return of(this.config.mode); + } + + if (this.config.breakpoint) { + return this.breakpointService + .isUp(this.config.breakpoint) + .pipe( + map((isUp: boolean) => (isUp ? TAB_MODE.TAB : TAB_MODE.ACCORDIAN)) + ); + } + + return of(TAB_MODE.TAB); + } +} diff --git a/projects/storefrontlib/cms-components/content/tab/tab.model.ts b/projects/storefrontlib/cms-components/content/tab/tab.model.ts new file mode 100644 index 00000000000..ccd82719e20 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.model.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TemplateRef } from '@angular/core'; +import { Translatable } from '@spartacus/core'; +import { BREAKPOINT } from '../../../layout/config/layout-config'; + +export interface Tab { + /** + * Name the tab with an i18n key. + */ + headerKey?: string; + /** + * Name the tab with a string. + */ + header?: string; + /** + * Content to display in tab panel when open. + */ + content: TemplateRef; + /** + * Identifies the index of the tab to set attributes by. + */ + id?: number; +} + +export interface TabConfig { + /** + * Translatable key to set aria-label of tablist. + */ + label?: Translatable | string; + /** + * Use this to set the tab mode. Defaults to 'TAB' when not set. + */ + mode?: TAB_MODE; + /** + * Breakpoint to switch responsively between 'TAB' and 'ACCORDIAN' modes. + * Uses 'ACCORDIAN' mode when under the breakpoint and 'TAB' mode when over it. + * Set this to use responsive modes. + */ + breakpoint?: BREAKPOINT; + /** + * The indexes of tabs to have open initially. + */ + openTabs?: number[]; +} + +export enum TAB_MODE { + TAB = 'TAB', + ACCORDIAN = 'ACCORDIAN', +} diff --git a/projects/storefrontlib/cms-components/content/tab/tab.module.ts b/projects/storefrontlib/cms-components/content/tab/tab.module.ts new file mode 100644 index 00000000000..528a7e41d04 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.module.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { TabComponent } from './tab.component'; +import { TabPanelModule } from './panel/tab-panel.module'; + +@NgModule({ + imports: [CommonModule, I18nModule, TabPanelModule], + declarations: [TabComponent], + exports: [TabComponent], +}) +export class TabModule {} diff --git a/projects/storefrontlib/cms-components/content/tab/tab.utils.spec.ts b/projects/storefrontlib/cms-components/content/tab/tab.utils.spec.ts new file mode 100644 index 00000000000..89bf9fe0809 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.utils.spec.ts @@ -0,0 +1,19 @@ +import { wrapIntoBounds } from './tab.utils'; + +describe('wrapIntoBounds', () => { + it('should wrap index exceeding max boundary to min boundary', () => { + expect(wrapIntoBounds(5, 3)).toBe(0); + }); + + it('should wrap index exceeding min boundary to max boundary', () => { + expect(wrapIntoBounds(-2, 3, 0)).toBe(3); + }); + + it('should return index if it does not exceed any boundary', () => { + expect(wrapIntoBounds(1, 5)).toBe(1); + }); + + it('should wrap index exceeding max boundary to min boundary when default min is used', () => { + expect(wrapIntoBounds(7, 3)).toBe(0); + }); +}); diff --git a/projects/storefrontlib/cms-components/content/tab/tab.utils.ts b/projects/storefrontlib/cms-components/content/tab/tab.utils.ts new file mode 100644 index 00000000000..307dca5eb02 --- /dev/null +++ b/projects/storefrontlib/cms-components/content/tab/tab.utils.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * If the passed index its outside of the min and max boundaries, + * wrap the index exceeding the max boundary to the min boundary or vice versa. + * Return the index if it does not exceed any boundary. + */ +export function wrapIntoBounds(index: number, max: number, min = 0): number { + if (index < min) { + return max; + } else if (index > max) { + return min; + } + + return index; +} diff --git a/projects/storefrontlib/cms-components/product/product-intro/product-intro.component.html b/projects/storefrontlib/cms-components/product/product-intro/product-intro.component.html index 125d2e060a8..e6abfe7ef0d 100644 --- a/projects/storefrontlib/cms-components/product/product-intro/product-intro.component.html +++ b/projects/storefrontlib/cms-components/product/product-intro/product-intro.component.html @@ -6,7 +6,7 @@