From ffe6c1935ebb5741445e66136702df0d48531afc Mon Sep 17 00:00:00 2001 From: "Elain T." Date: Mon, 28 Oct 2024 12:22:18 -0400 Subject: [PATCH] feat(design): create DaffTabsComponent (#3134) This adds a new tab component! You can use it like: ```html Tab 1 Tab 1 Panel ``` --------- Co-authored-by: Damien Retzinger --- apps/design-land/src/app/app.component.ts | 2 + libs/design/scss/theme.scss | 2 + libs/design/tabs/README.md | 19 ++ libs/design/tabs/examples/ng-package.json | 7 + .../src/basic-tabs/basic-tabs.component.html | 52 +++++ .../src/basic-tabs/basic-tabs.component.ts | 23 +++ .../custom-select-tabs.component.html | 63 ++++++ .../custom-select-tabs.component.scss | 7 + .../custom-select-tabs.component.ts | 42 ++++ .../disabled-tabs.component.html | 53 +++++ .../disabled-tabs/disabled-tabs.component.ts | 23 +++ libs/design/tabs/examples/src/index.ts | 1 + .../initially-select-tab.component.html | 53 +++++ .../initially-select-tab.component.ts | 23 +++ libs/design/tabs/examples/src/public_api.ts | 11 + libs/design/tabs/ng-package.json | 7 + libs/design/tabs/src/index.ts | 1 + libs/design/tabs/src/public_api.ts | 5 + libs/design/tabs/src/tabs-theme.scss | 22 ++ libs/design/tabs/src/tabs.ts | 14 ++ .../tab-activator.component.scss | 21 ++ .../tab-activator.component.spec.ts | 129 ++++++++++++ .../tab-activator/tab-activator.component.ts | 76 +++++++ .../tabs/tab-label/tab-label.component.html | 9 + .../tabs/tab-label/tab-label.component.scss | 13 ++ .../src/tabs/tab-label/tab-label.component.ts | 41 ++++ .../tabs/tab-panel/tab-panel.component.scss | 5 + .../tab-panel/tab-panel.component.spec.ts | 96 +++++++++ .../src/tabs/tab-panel/tab-panel.component.ts | 63 ++++++ .../tabs/src/tabs/tab/tab.component.spec.ts | 112 ++++++++++ .../design/tabs/src/tabs/tab/tab.component.ts | 83 ++++++++ libs/design/tabs/src/tabs/tabs.component.html | 24 +++ libs/design/tabs/src/tabs/tabs.component.scss | 15 ++ .../tabs/src/tabs/tabs.component.spec.ts | 185 +++++++++++++++++ libs/design/tabs/src/tabs/tabs.component.ts | 193 ++++++++++++++++++ 35 files changed, 1495 insertions(+) create mode 100644 libs/design/tabs/README.md create mode 100644 libs/design/tabs/examples/ng-package.json create mode 100644 libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.html create mode 100644 libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.ts create mode 100644 libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.html create mode 100644 libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.scss create mode 100644 libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.ts create mode 100644 libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.html create mode 100644 libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.ts create mode 100644 libs/design/tabs/examples/src/index.ts create mode 100644 libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.html create mode 100644 libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.ts create mode 100644 libs/design/tabs/examples/src/public_api.ts create mode 100644 libs/design/tabs/ng-package.json create mode 100644 libs/design/tabs/src/index.ts create mode 100644 libs/design/tabs/src/public_api.ts create mode 100644 libs/design/tabs/src/tabs-theme.scss create mode 100644 libs/design/tabs/src/tabs.ts create mode 100644 libs/design/tabs/src/tabs/tab-activator/tab-activator.component.scss create mode 100644 libs/design/tabs/src/tabs/tab-activator/tab-activator.component.spec.ts create mode 100644 libs/design/tabs/src/tabs/tab-activator/tab-activator.component.ts create mode 100644 libs/design/tabs/src/tabs/tab-label/tab-label.component.html create mode 100644 libs/design/tabs/src/tabs/tab-label/tab-label.component.scss create mode 100644 libs/design/tabs/src/tabs/tab-label/tab-label.component.ts create mode 100644 libs/design/tabs/src/tabs/tab-panel/tab-panel.component.scss create mode 100644 libs/design/tabs/src/tabs/tab-panel/tab-panel.component.spec.ts create mode 100644 libs/design/tabs/src/tabs/tab-panel/tab-panel.component.ts create mode 100644 libs/design/tabs/src/tabs/tab/tab.component.spec.ts create mode 100644 libs/design/tabs/src/tabs/tab/tab.component.ts create mode 100644 libs/design/tabs/src/tabs/tabs.component.html create mode 100644 libs/design/tabs/src/tabs/tabs.component.scss create mode 100644 libs/design/tabs/src/tabs/tabs.component.spec.ts create mode 100644 libs/design/tabs/src/tabs/tabs.component.ts diff --git a/apps/design-land/src/app/app.component.ts b/apps/design-land/src/app/app.component.ts index b32f9bec10..03e3dfaab3 100644 --- a/apps/design-land/src/app/app.component.ts +++ b/apps/design-land/src/app/app.component.ts @@ -27,6 +27,7 @@ import { PROGRESS_BAR_EXAMPLES } from '@daffodil/design/progress-bar/examples'; import { QUANTITY_FIELD_EXAMPLES } from '@daffodil/design/quantity-field/examples'; import { RADIO_EXAMPLES } from '@daffodil/design/radio/examples'; import { SIDEBAR_EXAMPLES } from '@daffodil/design/sidebar/examples'; +import { TABS_EXAMPLES } from '@daffodil/design/tabs/examples'; import { TOAST_EXAMPLES } from '@daffodil/design/toast/examples'; import { TREE_EXAMPLES } from '@daffodil/design/tree/examples'; @@ -68,6 +69,7 @@ export class DesignLandAppComponent { ...SIDEBAR_EXAMPLES, ...TOAST_EXAMPLES, ...TREE_EXAMPLES, + ...TABS_EXAMPLES, ].map((componentExample) => createCustomElementFromExample(componentExample, injector)) .map((customElement) => { // Register the custom element with the browser. diff --git a/libs/design/scss/theme.scss b/libs/design/scss/theme.scss index f8392df9e3..d4ed2cdcfa 100644 --- a/libs/design/scss/theme.scss +++ b/libs/design/scss/theme.scss @@ -40,6 +40,7 @@ @use '../sidebar/src/sidebar-theme' as sidebar; @use '../progress-bar/src/progress-bar-theme' as progress-bar; @use '../scss/state/skeleton/mixins' as skeleton; +@use '../tabs/src/tabs-theme' as tabs; @use '../tree/src/tree-theme' as tree; @use '../toast/src/toast-theme' as toast; @@ -83,6 +84,7 @@ @include notification.daff-notification-theme($theme); @include paginator.daff-paginator-theme($theme); @include sidebar.daff-sidebar-theme($theme); + @include tabs.daff-tabs-theme($theme); @include tree.daff-tree-theme($theme); @include toast.daff-toast-theme($theme); } diff --git a/libs/design/tabs/README.md b/libs/design/tabs/README.md new file mode 100644 index 0000000000..3832685ac2 --- /dev/null +++ b/libs/design/tabs/README.md @@ -0,0 +1,19 @@ +# Tabs +Tabs provide a way to navigate between panels that display related content. + +## Overview +Tabs allow for users to navigate between related content without having to leave the page. They can be used within components like modals or cards. + +## Accessbility +Tabs follow the [ARIA Tabs design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). Tabs compose of `tablist`, `tab`, and `tabpanel` elements, each with its appropriate role and integrated keyboard interactions. + +### Label +A meaningful `aria-label` should be set on `` by using the `aria-label` property. This will set the `aria-label` on the `tablist` element. + +### Keyboard Interactions +| Key | Action | +| --- | ------ | +| Left Arrow | Moves focus and activates previous tab. If focus is on the first tab, moves focus to the last tab. | +| Right Arrow | Moves focus and activates next tab. If focus is on the last tab, moves focus to the first tab. | +| Home | Moves focus and activates first tab. | +| End | Moves focus and activates last tab. | diff --git a/libs/design/tabs/examples/ng-package.json b/libs/design/tabs/examples/ng-package.json new file mode 100644 index 0000000000..e0bd308d7b --- /dev/null +++ b/libs/design/tabs/examples/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../../scss"] + } +} \ No newline at end of file diff --git a/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.html b/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.html new file mode 100644 index 0000000000..cc19b743ba --- /dev/null +++ b/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.html @@ -0,0 +1,52 @@ + + + + + Tab 1 + + + Tab 1 Panel + + + + + + Tab 2 + + + + Tab 2 Panel + + + + + + + Tab 3 + + + + Tab 3 Panel + + + + + + Tab 4 + + + + Tab 4 Panel + + + + + + Tab 5 + + + + Tab 5 Panel + + + \ No newline at end of file diff --git a/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.ts b/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.ts new file mode 100644 index 0000000000..a986092fb5 --- /dev/null +++ b/libs/design/tabs/examples/src/basic-tabs/basic-tabs.component.ts @@ -0,0 +1,23 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; + +import { DAFF_TABS_COMPONENTS } from '@daffodil/design/tabs'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'basic-tabs', + templateUrl: './basic-tabs.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DAFF_TABS_COMPONENTS, + FaIconComponent, + ], +}) +export class BasicTabsComponent { + faInfoCircle = faInfoCircle; +} diff --git a/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.html b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.html new file mode 100644 index 0000000000..a505b8bd9d --- /dev/null +++ b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.html @@ -0,0 +1,63 @@ +
+ + + +
+ + + + + + Tab 1 + + + Tab 1 Panel + + + + + + Tab 2 + + + + Tab 2 Panel + + + + + + + Tab 3 + + + + Tab 3 Panel + + + + + + Tab 4 + + + + Tab 4 Panel + + + + + + Tab 5 + + + + Tab 5 Panel + + + + \ No newline at end of file diff --git a/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.scss b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.scss new file mode 100644 index 0000000000..0a39d9ebd6 --- /dev/null +++ b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.scss @@ -0,0 +1,7 @@ +.custom-select-tabs { + &__buttons { + display: flex; + gap: 8px; + margin: 0 0 16px; + } +} \ No newline at end of file diff --git a/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.ts b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.ts new file mode 100644 index 0000000000..c2ff5cfa25 --- /dev/null +++ b/libs/design/tabs/examples/src/custom-select-tabs/custom-select-tabs.component.ts @@ -0,0 +1,42 @@ +import { + ChangeDetectionStrategy, + Component, + ViewChild, +} from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; + +import { DAFF_BUTTON_COMPONENTS } from '@daffodil/design/button'; +import { + DAFF_TABS_COMPONENTS, + DaffTabsComponent, +} from '@daffodil/design/tabs'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'custom-select-tabs', + templateUrl: './custom-select-tabs.component.html', + styleUrl: './custom-select-tabs.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DAFF_TABS_COMPONENTS, + DAFF_BUTTON_COMPONENTS, + FaIconComponent, + ], +}) +export class CustomSelectTabsComponent { + faInfoCircle = faInfoCircle; + + selectedTab = 'tab-3'; + + @ViewChild(DaffTabsComponent) _tab: DaffTabsComponent; + + selectTabThree() { + this._tab.select('tab-3'); + } + + selectTabFive() { + this._tab.select('tab-5'); + } +} diff --git a/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.html b/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.html new file mode 100644 index 0000000000..cda2c71064 --- /dev/null +++ b/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.html @@ -0,0 +1,53 @@ + + + + + Tab 1 + + + Tab 1 Panel + + + + + + Tab 2 + + + + Tab 2 Panel + + + + + + + Tab 3 + + + + Tab 3 Panel + + + + + + Tab 4 + + + + Tab 4 Panel + + + + + + Tab 5 + + + + Tab 5 Panel + + + + \ No newline at end of file diff --git a/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.ts b/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.ts new file mode 100644 index 0000000000..e1da4dc96a --- /dev/null +++ b/libs/design/tabs/examples/src/disabled-tabs/disabled-tabs.component.ts @@ -0,0 +1,23 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; + +import { DAFF_TABS_COMPONENTS } from '@daffodil/design/tabs'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'disabled-tabs', + templateUrl: './disabled-tabs.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DAFF_TABS_COMPONENTS, + FaIconComponent, + ], +}) +export class DisabledTabsComponent { + faInfoCircle = faInfoCircle; +} diff --git a/libs/design/tabs/examples/src/index.ts b/libs/design/tabs/examples/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/tabs/examples/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.html b/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.html new file mode 100644 index 0000000000..e66fd702eb --- /dev/null +++ b/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.html @@ -0,0 +1,53 @@ + + + + + Tab 1 + + + Tab 1 Panel + + + + + + Tab 2 + + + + Tab 2 Panel + + + + + + + Tab 3 + + + + Tab 3 Panel + + + + + + Tab 4 + + + + Tab 4 Panel + + + + + + Tab 5 + + + + Tab 5 Panel + + + + \ No newline at end of file diff --git a/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.ts b/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.ts new file mode 100644 index 0000000000..b868a719d4 --- /dev/null +++ b/libs/design/tabs/examples/src/initially-select-tab/initially-select-tab.component.ts @@ -0,0 +1,23 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; + +import { DAFF_TABS_COMPONENTS } from '@daffodil/design/tabs'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'initially-select-tab', + templateUrl: './initially-select-tab.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + DAFF_TABS_COMPONENTS, + FaIconComponent, + ], +}) +export class InitiallySelectTabComponent { + faInfoCircle = faInfoCircle; +} diff --git a/libs/design/tabs/examples/src/public_api.ts b/libs/design/tabs/examples/src/public_api.ts new file mode 100644 index 0000000000..4f09fb44fa --- /dev/null +++ b/libs/design/tabs/examples/src/public_api.ts @@ -0,0 +1,11 @@ +import { BasicTabsComponent } from './basic-tabs/basic-tabs.component'; +import { CustomSelectTabsComponent } from './custom-select-tabs/custom-select-tabs.component'; +import { DisabledTabsComponent } from './disabled-tabs/disabled-tabs.component'; +import { InitiallySelectTabComponent } from './initially-select-tab/initially-select-tab.component'; + +export const TABS_EXAMPLES = [ + BasicTabsComponent, + DisabledTabsComponent, + InitiallySelectTabComponent, + CustomSelectTabsComponent, +]; diff --git a/libs/design/tabs/ng-package.json b/libs/design/tabs/ng-package.json new file mode 100644 index 0000000000..c3baf7c1eb --- /dev/null +++ b/libs/design/tabs/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../scss"] + } +} \ No newline at end of file diff --git a/libs/design/tabs/src/index.ts b/libs/design/tabs/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/design/tabs/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/design/tabs/src/public_api.ts b/libs/design/tabs/src/public_api.ts new file mode 100644 index 0000000000..cd8f68d936 --- /dev/null +++ b/libs/design/tabs/src/public_api.ts @@ -0,0 +1,5 @@ +export { DAFF_TABS_COMPONENTS } from './tabs'; +export { DaffTabsComponent } from './tabs/tabs.component'; +export { DaffTabPanelComponent } from './tabs/tab-panel/tab-panel.component'; +export { DaffTabComponent } from './tabs/tab/tab.component'; +export { DaffTabLabelComponent } from './tabs/tab-label/tab-label.component'; diff --git a/libs/design/tabs/src/tabs-theme.scss b/libs/design/tabs/src/tabs-theme.scss new file mode 100644 index 0000000000..d3f50caa68 --- /dev/null +++ b/libs/design/tabs/src/tabs-theme.scss @@ -0,0 +1,22 @@ +@use 'sass:map'; +@use '../../scss/core'; +@use '../../scss/theming'; + +@mixin daff-tabs-theme($theme) { + $primary: map.get($theme, primary); + $secondary: map.get($theme, secondary); + $tertiary: map.get($theme, tertiary); + $neutral: core.daff-map-deep-get($theme, 'core.neutral'); + $base: core.daff-map-deep-get($theme, 'core.base'); + $base-contrast: core.daff-map-deep-get($theme, 'core.base-contrast'); + $white: core.daff-map-deep-get($theme, 'core.white'); + $black: core.daff-map-deep-get($theme, 'core.black'); + + .daff-tab-activator { + border-bottom: 2px solid theming.daff-illuminate($base, $neutral, 2); + + &.selected { + border-bottom: 2px solid theming.daff-color($primary); + } + } +} diff --git a/libs/design/tabs/src/tabs.ts b/libs/design/tabs/src/tabs.ts new file mode 100644 index 0000000000..d52b934c4a --- /dev/null +++ b/libs/design/tabs/src/tabs.ts @@ -0,0 +1,14 @@ +import { DaffPrefixSuffixModule } from '@daffodil/design'; + +import { DaffTabComponent } from './tabs/tab/tab.component'; +import { DaffTabLabelComponent } from './tabs/tab-label/tab-label.component'; +import { DaffTabPanelComponent } from './tabs/tab-panel/tab-panel.component'; +import { DaffTabsComponent } from './tabs/tabs.component'; + +export const DAFF_TABS_COMPONENTS = [ + DaffTabsComponent, + DaffTabLabelComponent, + DaffTabPanelComponent, + DaffPrefixSuffixModule, + DaffTabComponent, +]; diff --git a/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.scss b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.scss new file mode 100644 index 0000000000..45962f3b92 --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.scss @@ -0,0 +1,21 @@ +@use '../../../../scss/interactions'; + +.daff-tab-activator { + @include interactions.clickable(); + display: inline-block; + appearance: none; + background: none; + border: none; + color: currentColor; + font-size: 1rem; + font-weight: 400; + height: 2.5rem; + margin: 0; + padding: 0.5rem 1.5rem; + + &[disabled] { + cursor: not-allowed; + opacity: 0.6; + } +} + diff --git a/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.spec.ts b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.spec.ts new file mode 100644 index 0000000000..b0e841132f --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.spec.ts @@ -0,0 +1,129 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffTabActivatorComponent } from './tab-activator.component'; + +@Component({ + template: ` + + Tab Activator + `, + standalone: true, + imports: [ + DaffTabActivatorComponent, + ], +}) +class WrapperComponent { + selected: boolean; + tabActivatorId: string; + panelId: string; +} + +describe('@daffodil/design/tabs | DaffTabActivatorComponent', () => { + let wrapper: WrapperComponent; + let fixture: ComponentFixture; + let component: DaffTabActivatorComponent; + let de: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + + de = fixture.debugElement.query(By.css('[daff-tab-activator]')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should add a class of "daff-tab-activator" to the host element', () => { + expect(de.classes).toEqual(jasmine.objectContaining({ + 'daff-tab-activator': true, + })); + }); + + it('should set the role to tab', () => { + expect(component.role).toBe('tab'); + }); + + it('should take selected as an input', () => { + expect(component.selected).toEqual(wrapper.selected); + }); + + it('should take panelId as an input', () => { + expect(component.panelId).toEqual(wrapper.panelId); + }); + + describe('when selected is true', () => { + beforeEach(() => { + wrapper.selected = true; + fixture.detectChanges(); + }); + + it('should set aria-selected to true', () => { + expect(component.ariaSelected).toBe(true); + }); + + it('should set tabindex to 0', () => { + expect(component.tabIndex).toBe('0'); + }); + }); + + describe('when selected is false', () => { + beforeEach(() => { + wrapper.selected = false; + fixture.detectChanges(); + }); + + it('should set aria-selected to false', () => { + expect(component.ariaSelected).toBe(false); + }); + + it('should set tabindex to -1', () => { + expect(component.tabIndex).toBe('-1'); + }); + }); + + it('should assign the value of panelId to ariaControls', () => { + component.ngOnInit(); + + expect(component.ariaControls).toBe(component.panelId); + }); + + describe('tabActivatorId', () => { + it('should take tabActivatorId as an input', () => { + expect(component.tabActivatorId).toEqual(wrapper.tabActivatorId); + }); + + it('should assign the `tabActivatorId` value to the `id` attribute', () => { + expect(de.attributes['id']).toBe(component.tabActivatorId); + }); + }); + + it('should call the native element focus method', () => { + spyOn(de.nativeElement, 'focus'); + + component.focus(); + + expect(de.nativeElement.focus).toHaveBeenCalledWith(); + }); +}); diff --git a/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.ts b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.ts new file mode 100644 index 0000000000..16ce54c64d --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-activator/tab-activator.component.ts @@ -0,0 +1,76 @@ +import { + HostBinding, + Input, + OnInit, + Component, + ChangeDetectionStrategy, + ViewEncapsulation, + ElementRef, +} from '@angular/core'; + +@Component({ + standalone: true, + selector: '' + + 'button[daff-tab-activator]' + ',' + + 'a[daff-tab-activator]', + template: ``, + styleUrl: './tab-activator.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class DaffTabActivatorComponent implements OnInit { + /** + * @docs-private + */ + @HostBinding('class.daff-tab-activator') class = true; + + /** + * Sets the `role` to tab. + */ + @HostBinding('attr.role') role = 'tab'; + + /** Whether or not a tab is selected */ + @Input() @HostBinding('class.selected') selected = false; + + /** + * Sets `aria-selected` to true if the component is selected and false if it's not selected. + */ + @HostBinding('attr.aria-selected') get ariaSelected() { + return this.selected ? true : false; + } + + /** + * Sets `tabindex` to `0` if the component is selected and `-1` if it's not selected. + */ + @HostBinding('attr.tabindex') get tabIndex() { + return this.selected ? '0' : '-1'; + } + + @HostBinding('attr.aria-controls') ariaControls = ''; + + /** + * The html id of the tab activator component + */ + @Input() @HostBinding('attr.id') tabActivatorId = ''; + + @Input() panelId = ''; + + ngOnInit() { + /** + * Sets the value of `panelId` to the `ariaControls` property + */ + this.ariaControls = this.panelId; + } + + constructor( + private el: ElementRef, + ) { + } + + /** + * Sets focus to the native element of the component + */ + focus() { + this.el.nativeElement.focus(); + } +} diff --git a/libs/design/tabs/src/tabs/tab-label/tab-label.component.html b/libs/design/tabs/src/tabs/tab-label/tab-label.component.html new file mode 100644 index 0000000000..58e4b47e9e --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-label/tab-label.component.html @@ -0,0 +1,9 @@ + + + +
+ +
+ + + \ No newline at end of file diff --git a/libs/design/tabs/src/tabs/tab-label/tab-label.component.scss b/libs/design/tabs/src/tabs/tab-label/tab-label.component.scss new file mode 100644 index 0000000000..a33339d473 --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-label/tab-label.component.scss @@ -0,0 +1,13 @@ +@use '../../../../scss/typography' as t; + +:host { + display: flex; + gap: 8px; +} + +.daff-tab-label { + &__content { + @include t.text-truncate(); + max-width: 240px; + } +} diff --git a/libs/design/tabs/src/tabs/tab-label/tab-label.component.ts b/libs/design/tabs/src/tabs/tab-label/tab-label.component.ts new file mode 100644 index 0000000000..6dab97a4f6 --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-label/tab-label.component.ts @@ -0,0 +1,41 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, +} from '@angular/core'; + +import { + DaffPrefixDirective, + DaffSuffixDirective, + DaffPrefixable, + DaffSuffixable, + DaffPrefixSuffixModule, +} from '@daffodil/design'; + +/** + * DaffTabLabelComponent is used to display the label of a tab panel. Labels may optionally contain a `daffPrefix` or `daffSuffix` to add icons or badges. + * + * ```html + * + *
+ * Label + *
+ * ``` + */ +@Component({ + standalone: true, + selector: 'daff-tab-label', + templateUrl: './tab-label.component.html', + styleUrl: './tab-label.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgIf, + DaffPrefixSuffixModule, + ], +}) +export class DaffTabLabelComponent implements DaffPrefixable, DaffSuffixable { + @ContentChild(DaffPrefixDirective) _prefix: DaffPrefixDirective; + @ContentChild(DaffSuffixDirective) _suffix: DaffSuffixDirective; +} diff --git a/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.scss b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.scss new file mode 100644 index 0000000000..11434b9f1b --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.scss @@ -0,0 +1,5 @@ +:host(.daff-tab-panel) { + $root: '.daff-tab-panel'; + display: block; + padding: 1rem; +} \ No newline at end of file diff --git a/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.spec.ts b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.spec.ts new file mode 100644 index 0000000000..ba3b2294f6 --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.spec.ts @@ -0,0 +1,96 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffTabPanelComponent } from './tab-panel.component'; +import { DaffTabComponent } from '../tab/tab.component'; + +@Component({ + template: ` + + `, + standalone: true, + imports: [ + DaffTabPanelComponent, + ], +}) +class WrapperComponent { + selected: boolean; +} + +describe('@daffodil/design/tabs | DaffTabPanelComponent', () => { + let wrapper: WrapperComponent; + let fixture: ComponentFixture; + let component: DaffTabPanelComponent; + let de: DebugElement; + let mockTabComponent: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + mockTabComponent = jasmine.createSpyObj('DaffTabComponent', [], { + id: 'mock-tab-id', + panelId: 'mock-panel-id', + }); + + TestBed.configureTestingModule({ + imports: [ + WrapperComponent, + ], + providers: [ + { + provide: DaffTabComponent, + useValue: mockTabComponent, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + + de = fixture.debugElement.query(By.css('daff-tab-panel')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should add a class of "daff-tab-panel" to the host element', () => { + expect(de.classes).toEqual(jasmine.objectContaining({ + 'daff-tab-panel': true, + })); + }); + + it('should set the role to tabpanel', () => { + expect(component.role).toBe('tabpanel'); + }); + + it('should set the tabindex to 0', () => { + expect(component.tabIndex).toBe('0'); + }); + + it('should assign the `ariaLabelledBy` value to the `aria-labelledby` attribute', () => { + expect(de.attributes['aria-labelledby']).toBe(component.ariaLabelledBy); + }); + + it('should assign the `tabPanelId` value to the `id` attribute', () => { + expect(de.attributes['id']).toBe(component.tabPanelId); + }); + + it('should set ariaLabelledBy to the tab id', () => { + expect(component.ariaLabelledBy).toBe(mockTabComponent.id); + }); + + it('should set _id to the tab panelId', () => { + expect(component['_id']).toBe(mockTabComponent.panelId); + }); +}); diff --git a/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.ts b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.ts new file mode 100644 index 0000000000..dba50cbe13 --- /dev/null +++ b/libs/design/tabs/src/tabs/tab-panel/tab-panel.component.ts @@ -0,0 +1,63 @@ +import { + Component, + HostBinding, + ChangeDetectionStrategy, +} from '@angular/core'; + +import { DaffTabComponent } from '../tab/tab.component'; + +/** + * DaffTabPanelComponent is used to display the content panel of a tab. + * + * ```html + * + * + * + * ``` + */ +@Component({ + standalone: true, + selector: 'daff-tab-panel', + template: ``, + styleUrl: './tab-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DaffTabPanelComponent { + /** + * @docs-private + */ + @HostBinding('class.daff-tab-panel') private class = true; + + /** + * Sets the `role` to tabpanel. + */ + @HostBinding('attr.role') role = 'tabpanel'; + + /** + * `aria-labelledby` for the tab. + */ + @HostBinding('attr.aria-labelledby') ariaLabelledBy = ''; + + /** + * Sets the `tabindex` to 0. + */ + @HostBinding('attr.tabindex') tabIndex = '0'; + + private _id = ''; + + /** + * Dynamically binds the tab panel's id to a unique value generated from the associated tab's panelId. + */ + @HostBinding('attr.id') get tabPanelId() { + return this._id; + } + + constructor(private tab: DaffTabComponent) { + /** + * Sets the value of `ariaLabelledBy` to the id of the tab component. + */ + this.ariaLabelledBy = this.tab.id; + + this._id = this.tab.panelId; + } +} diff --git a/libs/design/tabs/src/tabs/tab/tab.component.spec.ts b/libs/design/tabs/src/tabs/tab/tab.component.spec.ts new file mode 100644 index 0000000000..97133ea1db --- /dev/null +++ b/libs/design/tabs/src/tabs/tab/tab.component.spec.ts @@ -0,0 +1,112 @@ +import { + Component, + DebugElement, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffTabComponent } from './tab.component'; + +@Component({ + template: ` + + `, + standalone: true, + imports: [ + DaffTabComponent, + ], +}) +class WrapperComponent { + disabled: boolean; +} + +describe('@daffodil/design/tabs | DaffTabComponent | Defaults', () => { + let wrapper: WrapperComponent; + let fixture: ComponentFixture; + let component: DaffTabComponent; + let de: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + WrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + + de = fixture.debugElement.query(By.css('daff-tab')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should take disabled as an input', () => { + expect(component.disabled).toEqual(wrapper.disabled); + }); + + it('should have a generated id', () => { + expect(component.id).toMatch('daff-tab-[0-9]*'); + }); +}); + +@Component({ + template: ` + + `, + standalone: true, + imports: [ + DaffTabComponent, + ], +}) +class IdWrapperComponent { + id: string; +} + +describe('@daffodil/design/tabs | DaffTabComponent | Custom Id', () => { + let wrapper: IdWrapperComponent; + let fixture: ComponentFixture; + let component: DaffTabComponent; + let de: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IdWrapperComponent, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IdWrapperComponent); + wrapper = fixture.componentInstance; + + de = fixture.debugElement.query(By.css('daff-tab')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should take id as an input', () => { + expect(component.id).toEqual(wrapper.id); + }); + + it('should allow a custom id to be set', () => { + expect(component.id).toEqual(wrapper.id); + }); +}); diff --git a/libs/design/tabs/src/tabs/tab/tab.component.ts b/libs/design/tabs/src/tabs/tab/tab.component.ts new file mode 100644 index 0000000000..992df5053c --- /dev/null +++ b/libs/design/tabs/src/tabs/tab/tab.component.ts @@ -0,0 +1,83 @@ +import { + Component, + ChangeDetectionStrategy, + TemplateRef, + ViewChild, + Input, +} from '@angular/core'; + +let tabId = 1; + +/** + * `DaffTabComponet` is an element in the tab list that is used as a content container to group the label of a tab panel and the tab panel together. + * + * ## Template Structure + * A `` should include the {@link DaffTabLabelComponent} and {@link DaffTabPanelComponent} components in order to properly structure the UI. + * + * ## Usage + * ```html + * + * + * + * Tab 1 + * + * + * Tab 1 Panel + * + * + * ``` + */ +@Component({ + standalone: true, + selector: 'daff-tab', + template: ` + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DaffTabComponent { + /** + * Whether the tab is disabled. + * + * ```html + * + * + * ``` + */ + @Input() disabled = false; + + /** + * @docs-private + */ + @ViewChild('content', { read: TemplateRef, static: true }) contentRef: TemplateRef; + + /** + * @docs-private + */ + @ViewChild('label', { read: TemplateRef, static: true }) labelRef: TemplateRef; + + /** + * A unique id for the tab component. + * + * The `id` is automatically generated by linking the prefix 'daff-tab-' with an incrementing `tabId`. This value can be customized by passing a different `id` value via the component's `id` input. + * + * ```html + * + * ``` + */ + @Input() id = 'daff-tab-' + tabId; + + /** + * @docs-private + */ + panelId = 'daff-tab-panel-' + tabId; + + constructor() { + tabId++; + } +} diff --git a/libs/design/tabs/src/tabs/tabs.component.html b/libs/design/tabs/src/tabs/tabs.component.html new file mode 100644 index 0000000000..52393b92d7 --- /dev/null +++ b/libs/design/tabs/src/tabs/tabs.component.html @@ -0,0 +1,24 @@ +
+ @for (tab of _tabs; track tab) { + + } +
+ +@for (tab of _tabs; track tab) { + @if(tab.id === selectedTab ) { + + } +} \ No newline at end of file diff --git a/libs/design/tabs/src/tabs/tabs.component.scss b/libs/design/tabs/src/tabs/tabs.component.scss new file mode 100644 index 0000000000..4b9a1424f1 --- /dev/null +++ b/libs/design/tabs/src/tabs/tabs.component.scss @@ -0,0 +1,15 @@ +:host(.daff-tabs) { + $root: '.daff-tabs'; + max-width: 100%; +} + +.daff-tabs { + $root: &; + display: block; + + #{$root}__tab-list { + display: flex; + overflow-x: scroll; + scrollbar-width: thin; + } +} diff --git a/libs/design/tabs/src/tabs/tabs.component.spec.ts b/libs/design/tabs/src/tabs/tabs.component.spec.ts new file mode 100644 index 0000000000..f84f3795f9 --- /dev/null +++ b/libs/design/tabs/src/tabs/tabs.component.spec.ts @@ -0,0 +1,185 @@ +import { + ChangeDetectorRef, + Component, + DebugElement, + QueryList, +} from '@angular/core'; +import { + waitForAsync, + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { DaffTabComponent } from './tab/tab.component'; +import { DaffTabActivatorComponent } from './tab-activator/tab-activator.component'; +import { DaffTabLabelComponent } from './tab-label/tab-label.component'; +import { DaffTabsComponent } from './tabs.component'; +import { DAFF_TABS_COMPONENTS } from '../tabs'; + +@Component({ + template: ` + + + + Tab 1 + + + Tab 1 Panel + + + + + + Tab 2 + + + Tab 2 Panel + + + + + + Tab 2 + + + Tab 2 Panel + + + + `, + standalone: true, + imports: [ + DAFF_TABS_COMPONENTS, + ], +}) +class WrapperComponent { + changed: string | null = null; + + onTabChange(val: string) { + this.changed = val; + } +} + +describe('@daffodil/design/tabs | DaffTabsComponent', () => { + let wrapper: WrapperComponent; + let fixture: ComponentFixture; + let component: DaffTabsComponent; + let de: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + WrapperComponent, + ], + providers: [ + ChangeDetectorRef, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WrapperComponent); + wrapper = fixture.componentInstance; + + de = fixture.debugElement.query(By.css('daff-tabs')); + component = de.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(wrapper).toBeTruthy(); + }); + + it('should add a class of "daff-tabs" to the host element', () => { + expect(de.classes).toEqual(jasmine.objectContaining({ + 'daff-tabs': true, + })); + }); + + it('should set selectedTab to initiallySelected if provided', () => { + component.initiallySelected = 'tab-2'; + component.ngAfterContentInit(); + expect(component._tabs.toArray()[1].id).toEqual(component.initiallySelected); + }); + + it('should display a warning if select is called with a tab that does not exist', () => { + console.warn = jasmine.createSpy('warn'); + + component.select('custom-tab'); + + expect(console.warn).toHaveBeenCalledWith(`The tab 'custom-tab' was not able to be selected because it does not exist. Check the id on your s.`); + }); + + it('should set selectedTab to the first tab if initiallySelected is not provided', () => { + component.initiallySelected = null; + component.ngAfterContentInit(); + expect(component.selectedTab).toBe(component._tabs.toArray()[0].id); + }); + + it('should emit tabChange when a tab is selected', () => { + const id = component._tabs.toArray()[1].id; + + wrapper.changed = null; + component.select(id); + expect(wrapper.changed).toEqual(id); + }); + + it('should focus on the selected tab when select is called', () => { + const index = 1; + const tab = fixture.debugElement.queryAll(By.directive(DaffTabActivatorComponent))[index].nativeElement; + + component.select(component._tabs.toArray()[index].id); + + expect(document.activeElement).toEqual(tab); + }); + + it('should navigate to the previous tab when previous is called', () => { + component.select(component._tabs.toArray()[1].id); + + component.previous(); + + const previousTab = fixture.debugElement.queryAll(By.directive(DaffTabActivatorComponent))[0].nativeElement; + + expect(document.activeElement).toEqual(previousTab); + }); + + it('should navigate to the next tab when next is called', () => { + component.select(component._tabs.toArray()[0].id); + + component.next(); + + const nextTab = fixture.debugElement.queryAll(By.directive(DaffTabActivatorComponent))[1].nativeElement; + + expect(document.activeElement).toEqual(nextTab); + }); + + it('should skip disabled tabs when navigating', () => { + component.select(component._tabs.toArray()[0].id); + + const tab = component._tabs.toArray()[1]; + tab.disabled = true; + fixture.detectChanges(); + + component.next(); + + expect(component.selectedTab).toBe(component._tabs.toArray()[2].id); + }); + + it('should select the first tab when selectFirst is called', () => { + const event = new KeyboardEvent('keydown'); + component.selectFirst(event); + + expect(component.selectedTab).toBe(component._tabs.toArray()[0].id); + }); + + it('should select the first tab when selectLast is called', () => { + const event = new KeyboardEvent('keydown'); + component.selectLast(event); + + const lastTab = component._tabs.toArray()[component._tabs.toArray().length - 1].id; + + expect(component.selectedTab).toBe(lastTab); + }); +}); diff --git a/libs/design/tabs/src/tabs/tabs.component.ts b/libs/design/tabs/src/tabs/tabs.component.ts new file mode 100644 index 0000000000..467a2135e0 --- /dev/null +++ b/libs/design/tabs/src/tabs/tabs.component.ts @@ -0,0 +1,193 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + HostBinding, + ViewEncapsulation, + ChangeDetectionStrategy, + ContentChildren, + QueryList, + AfterContentInit, + Input, + Output, + EventEmitter, + ViewChildren, + ChangeDetectorRef, +} from '@angular/core'; + +import { DaffArticleEncapsulatedDirective } from '@daffodil/design'; + +import { DaffTabComponent } from './tab/tab.component'; +import { DaffTabActivatorComponent } from './tab-activator/tab-activator.component'; +import { DaffTabLabelComponent } from './tab-label/tab-label.component'; + +/** + * Tabs provide a way to navigate between panels that display related content. + * + * ## Usage + * ```html + * + * + * + * + * Tab 1 + * + * + * Tab 1 Panel + * + * + * + * + * Tab 2 + * + * + * + * Tab 2 Panel + * + * + * + * ``` + */ +@Component({ + standalone: true, + selector: 'daff-tabs', + templateUrl: './tabs.component.html', + styleUrl: './tabs.component.scss', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgTemplateOutlet, + DaffTabActivatorComponent, + ], + hostDirectives: [ + { directive: DaffArticleEncapsulatedDirective }, + ], +}) + +export class DaffTabsComponent implements AfterContentInit { + /** + * @docs-private + */ + @HostBinding('class.daff-tabs') private class = true; + + /** + * The currently selected tab. This property is dynamically updated when a user selects a tab + */ + selectedTab: string; + + /** + * The tab that is initially selected on initial load. If it's not used, the first tab in the tablist will be selected by default. + */ + @Input() initiallySelected: string = null; + + /** + * @docs-private + */ + @HostBinding('attr.aria-label') private externalAriaLabel = null; + + /** + * aria-label for the tab. + */ + @Input('aria-label') ariaLabel = ''; + + /** + * Event emitted when tab selection changes. + */ + @Output() tabChange = new EventEmitter(); + + /** + * @docs-private + */ + @ContentChildren(DaffTabLabelComponent, { descendants: true }) _labels: QueryList; + + /** + * @docs-private + */ + @ContentChildren(DaffTabComponent) _tabs: QueryList; + + /** + * @docs-private + */ + @ViewChildren(DaffTabActivatorComponent) _tabActivators: QueryList; + + constructor(private cdRef: ChangeDetectorRef) {} + + /** + * @docs-private + */ + ngAfterContentInit() { + if(this.initiallySelected) { + this.selectedTab = this.initiallySelected; + } + + if (!this.selectedTab) { + this.selectedTab = this._tabs.first.id; + } + } + + /** + * Selects a tab and sets focus on the selected tab. + */ + select(tabId: string) { + const tabActivator = this._tabActivators.find(el => el.tabActivatorId === tabId); + + if (!tabActivator) { + console.warn(`The tab '${tabId}' was not able to be selected because it does not exist. Check the id on your s.`); + return; + } + + this.tabChange.emit(tabId); + this.selectedTab = tabId; + this.cdRef.markForCheck(); + + tabActivator.focus(); + } + + /** + * Navigates through the tabs based on the given offset. + * Moves forward or backward in the tab array, wrapping around when necessary. + */ + private navigateTabs(offset: number) { + const array = this._tabs.toArray(); + let selectedIndex = array.findIndex(el => el.id === this.selectedTab); + const startingIndex = selectedIndex; + let newIndex; + + do { + newIndex = (selectedIndex + offset + array.length) % array.length; + selectedIndex = newIndex; + } while (array[newIndex].disabled && selectedIndex !== startingIndex); // Skip disabled tabs + + this.select(array[newIndex].id); + } + + /** + * Selects the previous tab and wraps around to the last tab if the first tab is currently selected. + */ + previous() { + this.navigateTabs(-1); + } + + /** + * Selects the next tab and wraps around to the first tab if the last tab is currently selected. + */ + next() { + this.navigateTabs(1); + } + + /** + * Selects the first tab. + */ + selectFirst(event: KeyboardEvent | null) { + event.preventDefault(); + this.select(this._tabs.toArray()[0].id); + } + + /** + * Selects the last tab. + */ + selectLast(event: KeyboardEvent | null) { + event.preventDefault(); + const array = this._tabs.toArray(); + this.select(array[array.length - 1].id); + } +}