From 4173c319a64f9c600b3cfbda2b90f2b336ea5f1c Mon Sep 17 00:00:00 2001 From: Jonathan Meyer Date: Wed, 23 Oct 2024 14:04:06 -0500 Subject: [PATCH 1/6] Scrollable tabs (#2440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## 🤨 Rationale - #1509 --- ...-51de64d0-1537-4dce-89b5-e85857542ebb.json | 7 + .../src/anchor-tab/styles.ts | 1 + .../src/anchor-tabs/index.ts | 75 ++++++++- .../src/anchor-tabs/styles.ts | 23 --- .../src/anchor-tabs/template.ts | 18 --- .../testing/anchor-tabs.pageobject.ts | 13 ++ .../src/anchor-tabs/tests/anchor-tabs.spec.ts | 121 +++++++++++++- .../src/label-provider/core/index.ts | 14 +- .../core/label-token-defaults.ts | 4 +- .../src/label-provider/core/label-tokens.ts | 10 ++ .../src/patterns/tabs/styles.ts | 35 +++++ .../src/patterns/tabs/template.ts | 70 +++++++++ .../tabs/testing/tabs-base.pageobject.ts | 116 ++++++++++++++ .../src/patterns/tabs/types.ts | 9 ++ .../select/tests/select.foundation.spec.ts | 33 ++-- packages/nimble-components/src/tab/styles.ts | 1 + packages/nimble-components/src/tabs/index.ts | 86 +++++++++- packages/nimble-components/src/tabs/styles.ts | 24 +-- .../src/tabs/testing/tabs.pageobject.ts | 14 ++ .../src/tabs/tests/tabs.spec.ts | 147 ++++++++++++++++++ .../src/utilities/testing/component.ts | 9 ++ .../anchor-tabs/anchor-tabs-matrix.stories.ts | 11 +- .../nimble/anchor-tabs/anchor-tabs.stories.ts | 77 ++++++++- .../src/nimble/patterns/tabs/types.ts | 7 + .../src/nimble/tabs/tabs-matrix.stories.ts | 13 +- .../storybook/src/nimble/tabs/tabs.stories.ts | 71 +++++++-- 26 files changed, 883 insertions(+), 126 deletions(-) create mode 100644 change/@ni-nimble-components-51de64d0-1537-4dce-89b5-e85857542ebb.json delete mode 100644 packages/nimble-components/src/anchor-tabs/styles.ts delete mode 100644 packages/nimble-components/src/anchor-tabs/template.ts create mode 100644 packages/nimble-components/src/anchor-tabs/testing/anchor-tabs.pageobject.ts create mode 100644 packages/nimble-components/src/patterns/tabs/styles.ts create mode 100644 packages/nimble-components/src/patterns/tabs/template.ts create mode 100644 packages/nimble-components/src/patterns/tabs/testing/tabs-base.pageobject.ts create mode 100644 packages/nimble-components/src/patterns/tabs/types.ts create mode 100644 packages/nimble-components/src/tabs/testing/tabs.pageobject.ts create mode 100644 packages/storybook/src/nimble/patterns/tabs/types.ts diff --git a/change/@ni-nimble-components-51de64d0-1537-4dce-89b5-e85857542ebb.json b/change/@ni-nimble-components-51de64d0-1537-4dce-89b5-e85857542ebb.json new file mode 100644 index 0000000000..9a1b241e78 --- /dev/null +++ b/change/@ni-nimble-components-51de64d0-1537-4dce-89b5-e85857542ebb.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Scrollable tabs for Tabs and AnchorTabs.", + "packageName": "@ni/nimble-components", + "email": "26874831+atmgrifter00@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/anchor-tab/styles.ts b/packages/nimble-components/src/anchor-tab/styles.ts index 2cac7fdab4..2ff874da16 100644 --- a/packages/nimble-components/src/anchor-tab/styles.ts +++ b/packages/nimble-components/src/anchor-tab/styles.ts @@ -26,6 +26,7 @@ export const styles = css` align-items: center; justify-content: center; cursor: pointer; + text-wrap: nowrap; --ni-private-active-indicator-width: 2px; --ni-private-focus-indicator-width: 1px; --ni-private-indicator-lines-gap: 1px; diff --git a/packages/nimble-components/src/anchor-tabs/index.ts b/packages/nimble-components/src/anchor-tabs/index.ts index b8077de9d0..9203bf7e33 100644 --- a/packages/nimble-components/src/anchor-tabs/index.ts +++ b/packages/nimble-components/src/anchor-tabs/index.ts @@ -26,9 +26,10 @@ import { FoundationElementDefinition, FoundationElement } from '@microsoft/fast-foundation'; -import { styles } from './styles'; -import { template } from './template'; +import { styles } from '../patterns/tabs/styles'; +import { template } from '../patterns/tabs/template'; import type { AnchorTab } from '../anchor-tab'; +import type { TabsOwner } from '../patterns/tabs/types'; declare global { interface HTMLElementTagNameMap { @@ -41,7 +42,7 @@ export type TabsOptions = FoundationElementDefinition & StartEndOptions; /** * A nimble-styled set of anchor tabs */ -export class AnchorTabs extends FoundationElement { +export class AnchorTabs extends FoundationElement implements TabsOwner { /** * The id of the active tab * @@ -58,6 +59,12 @@ export class AnchorTabs extends FoundationElement { @observable public tabs!: HTMLElement[]; + /** + * @internal + */ + @observable + public showScrollButtons = false; + /** * A reference to the active tab * @public @@ -70,14 +77,44 @@ export class AnchorTabs extends FoundationElement { */ public tablist!: HTMLElement; + /** + * @internal + */ + public readonly leftScrollButton?: HTMLElement; + + /** + * @internal + */ + public readonly tabSlotName = 'anchortab'; + + private readonly tabListResizeObserver: ResizeObserver; private tabIds: string[] = []; + public constructor() { + super(); + this.tabListResizeObserver = new ResizeObserver(entries => { + let tabListVisibleWidth = entries[0]?.contentRect.width; + if (tabListVisibleWidth !== undefined) { + const buttonWidth = this.leftScrollButton?.clientWidth ?? 0; + tabListVisibleWidth = Math.ceil(tabListVisibleWidth); + if (this.showScrollButtons) { + tabListVisibleWidth += buttonWidth * 2; + } + this.showScrollButtons = tabListVisibleWidth < this.tablist.scrollWidth; + } + }); + } + /** * @internal */ public activeidChanged(_oldValue: string, _newValue: string): void { if (this.$fastController.isConnected) { this.setTabs(); + this.activetab?.scrollIntoView({ + block: 'nearest', + inline: 'start' + }); } } @@ -91,15 +128,43 @@ export class AnchorTabs extends FoundationElement { } } + /** + * @internal + */ + public onScrollLeftClick(): void { + this.tablist.scrollBy({ + left: -this.tablist.clientWidth, + behavior: 'smooth' + }); + } + + /** + * @internal + */ + public onScrollRightClick(): void { + this.tablist.scrollBy({ + left: this.tablist.clientWidth, + behavior: 'smooth' + }); + } + /** * @internal */ public override connectedCallback(): void { super.connectedCallback(); - + this.tabListResizeObserver.observe(this.tablist); this.tabIds = this.getTabIds(); } + /** + * @internal + */ + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.tabListResizeObserver.disconnect(); + } + private readonly isDisabledElement = (el: Element): el is HTMLElement => { return el.getAttribute('aria-disabled') === 'true'; }; @@ -277,6 +342,8 @@ export class AnchorTabs extends FoundationElement { tab === focusedTab ? 'true' : 'false' ); }); + + focusedTab.scrollIntoView({ block: 'nearest', inline: 'start' }); }; private getTabAnchor(tab: AnchorTab): HTMLAnchorElement { diff --git a/packages/nimble-components/src/anchor-tabs/styles.ts b/packages/nimble-components/src/anchor-tabs/styles.ts deleted file mode 100644 index c7611b7221..0000000000 --- a/packages/nimble-components/src/anchor-tabs/styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { css } from '@microsoft/fast-element'; -import { display } from '../utilities/style/display'; - -export const styles = css` - ${display('grid')} - - :host { - grid-template-columns: auto 1fr; - grid-template-rows: auto 1fr; - } - - [part='start'] { - display: none; - } - - .tablist { - display: grid; - grid-template-rows: auto auto; - grid-template-columns: auto; - width: max-content; - align-self: end; - } -`; diff --git a/packages/nimble-components/src/anchor-tabs/template.ts b/packages/nimble-components/src/anchor-tabs/template.ts deleted file mode 100644 index 6edd59c2ac..0000000000 --- a/packages/nimble-components/src/anchor-tabs/template.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { html, ref, slotted, ViewTemplate } from '@microsoft/fast-element'; -import { - endSlotTemplate, - FoundationElementTemplate, - startSlotTemplate -} from '@microsoft/fast-foundation'; -import type { AnchorTabs, TabsOptions } from '.'; - -export const template: FoundationElementTemplate< -ViewTemplate, -TabsOptions -> = (context, definition) => html` - ${startSlotTemplate(context, definition)} -
- -
- ${endSlotTemplate(context, definition)} -`; diff --git a/packages/nimble-components/src/anchor-tabs/testing/anchor-tabs.pageobject.ts b/packages/nimble-components/src/anchor-tabs/testing/anchor-tabs.pageobject.ts new file mode 100644 index 0000000000..a8b4caade1 --- /dev/null +++ b/packages/nimble-components/src/anchor-tabs/testing/anchor-tabs.pageobject.ts @@ -0,0 +1,13 @@ +import type { AnchorTabs } from '..'; +import { anchorTabTag } from '../../anchor-tab'; +import { TabsBasePageObject } from '../../patterns/tabs/testing/tabs-base.pageobject'; + +/** + * Page object for the `nimble-anchor-tabs` component to provide consistent ways + * of querying and interacting with the component during tests. + */ +export class AnchorTabsPageObject extends TabsBasePageObject { + public constructor(tabsElement: AnchorTabs) { + super(tabsElement, anchorTabTag); + } +} diff --git a/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts index e8bf245730..28b32d22a3 100644 --- a/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts +++ b/packages/nimble-components/src/anchor-tabs/tests/anchor-tabs.spec.ts @@ -14,11 +14,13 @@ import '../../anchor-tab'; import { anchorTabTag, type AnchorTab } from '../../anchor-tab'; import { waitForUpdatesAsync } from '../../testing/async-helpers'; import { fixture, Fixture } from '../../utilities/tests/fixture'; +import { AnchorTabsPageObject } from '../testing/anchor-tabs.pageobject'; describe('AnchorTabs', () => { let element: AnchorTabs; let connect: () => Promise; let disconnect: () => Promise; + let pageObject: AnchorTabsPageObject; function tab(index: number): AnchorTab { return element.children[index] as AnchorTab; @@ -42,6 +44,7 @@ describe('AnchorTabs', () => { beforeEach(async () => { ({ element, connect, disconnect } = await setup()); await connect(); + pageObject = new AnchorTabsPageObject(element); }); afterEach(async () => { @@ -118,8 +121,7 @@ describe('AnchorTabs', () => { expect(tab(0).tabIndex).toBe(-1); expect(tab(1).tabIndex).toBe(0); expect(tab(2).tabIndex).toBe(-1); - tab(0).dispatchEvent(new Event('click')); - await waitForUpdatesAsync(); + await pageObject.clickTab(0); expect(tab(0).tabIndex).toBe(0); expect(tab(1).tabIndex).toBe(-1); expect(tab(2).tabIndex).toBe(-1); @@ -130,9 +132,7 @@ describe('AnchorTabs', () => { anchor(0).addEventListener('click', () => { timesClicked += 1; }); - tab(0).dispatchEvent( - new KeyboardEvent('keydown', { key: keySpace }) - ); + await pageObject.pressKeyOnTab(0, keySpace); await waitForUpdatesAsync(); expect(timesClicked).toBe(1); }); @@ -142,9 +142,7 @@ describe('AnchorTabs', () => { anchor(0).addEventListener('click', () => { timesClicked += 1; }); - tab(0).dispatchEvent( - new KeyboardEvent('keydown', { key: keyEnter }) - ); + await pageObject.pressKeyOnTab(0, keyEnter); await waitForUpdatesAsync(); expect(timesClicked).toBe(1); }); @@ -374,4 +372,111 @@ describe('AnchorTabs', () => { expect(document.activeElement).toBe(tab(0)); }); }); + + describe('scroll buttons', () => { + async function setup(): Promise> { + return await fixture( + html`<${anchorTabsTag} activeid="tab-two"> + <${anchorTabTag}>Tab 1 + <${anchorTabTag} id="tab-two">Tab 2 + <${anchorTabTag} id="tab-three">Tab 3 + <${anchorTabTag} id="tab-four">Tab 4 + <${anchorTabTag} id="tab-five">Tab 5 + <${anchorTabTag} id="tab-six">Tab 6 + ` + ); + } + + let tabsPageObject: AnchorTabsPageObject; + + beforeEach(async () => { + ({ element, connect, disconnect } = await setup()); + await connect(); + tabsPageObject = new AnchorTabsPageObject(element); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('should not show scroll buttons when the tabs fit within the container', () => { + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + }); + + it('should show scroll buttons when the tabs overflow the container', async () => { + await tabsPageObject.setTabsWidth(300); + expect(tabsPageObject.areScrollButtonsVisible()).toBeTrue(); + }); + + it('should hide scroll buttons when the tabs no longer overflow the container', async () => { + await tabsPageObject.setTabsWidth(300); + await tabsPageObject.setTabsWidth(1000); + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + }); + + it('should scroll left when the left scroll button is clicked', async () => { + await tabsPageObject.setTabsWidth(300); + element.activeid = 'tab-six'; // scrolls to the last tab + const currentScrollOffset = tabsPageObject.getTabsViewScrollOffset(); + await tabsPageObject.clickScrollLeftButton(); + expect(tabsPageObject.getTabsViewScrollOffset()).toBeLessThan( + currentScrollOffset + ); + }); + + it('should not scroll left when the left scroll button is clicked and the first tab is active', async () => { + await tabsPageObject.setTabsWidth(300); + await tabsPageObject.clickScrollLeftButton(); + expect(tabsPageObject.getTabsViewScrollOffset()).toBe(0); + }); + + it('should scroll right when the right scroll button is clicked', async () => { + await tabsPageObject.setTabsWidth(300); + await tabsPageObject.clickScrollRightButton(); + expect(tabsPageObject.getTabsViewScrollOffset()).toBeGreaterThan(0); + }); + + it('should not scroll right when the right scroll button is clicked and the last tab is active', async () => { + await tabsPageObject.setTabsWidth(300); + element.activeid = 'tab-six'; // scrolls to the last tab + const currentScrollOffset = tabsPageObject.getTabsViewScrollOffset(); + await tabsPageObject.clickScrollRightButton(); + expect(tabsPageObject.getTabsViewScrollOffset()).toBe( + currentScrollOffset + ); + }); + + it('should show scroll buttons when new tab is added and tabs overflow the container', async () => { + await tabsPageObject.setTabsWidth(450); + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + await tabsPageObject.addTab('New Tab With Extremely Long Name'); + expect(tabsPageObject.areScrollButtonsVisible()).toBeTrue(); + }); + + it('should hide scroll buttons when tab is removed and tabs no longer overflow the container', async () => { + await tabsPageObject.setTabsWidth(500); + await tabsPageObject.addTab('New Tab With Extremely Long Name'); + expect(tabsPageObject.areScrollButtonsVisible()).toBeTrue(); + await tabsPageObject.removeTab(6); + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + }); + + it('should show scroll buttons when tab label is updated and tabs overflow the container', async () => { + await tabsPageObject.setTabsWidth(450); + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + await tabsPageObject.updateTabLabel( + 0, + 'New Tab With Extremely Long Name' + ); + expect(tabsPageObject.areScrollButtonsVisible()).toBeTrue(); + }); + + it('should hide scroll buttons when tab label is updated and tabs no longer overflow the container', async () => { + await tabsPageObject.setTabsWidth(550); + await tabsPageObject.addTab('New Tab With Extremely Long Name'); + expect(tabsPageObject.areScrollButtonsVisible()).toBeTrue(); + await tabsPageObject.updateTabLabel(6, 'Tab 6'); + expect(tabsPageObject.areScrollButtonsVisible()).toBeFalse(); + }); + }); }); diff --git a/packages/nimble-components/src/label-provider/core/index.ts b/packages/nimble-components/src/label-provider/core/index.ts index fcdedad25c..65c5a9667f 100644 --- a/packages/nimble-components/src/label-provider/core/index.ts +++ b/packages/nimble-components/src/label-provider/core/index.ts @@ -10,7 +10,9 @@ import { popupIconInformationLabel, filterSearchLabel, filterNoResultsLabel, - loadingLabel + loadingLabel, + scrollBackwardLabel, + scrollForwardLabel } from './label-tokens'; import { styles } from '../base/styles'; @@ -29,7 +31,9 @@ const supportedLabels = { popupIconInformation: popupIconInformationLabel, filterSearch: filterSearchLabel, filterNoResults: filterNoResultsLabel, - loading: loadingLabel + loading: loadingLabel, + scrollBackward: scrollBackwardLabel, + scrollForward: scrollForwardLabel } as const; /** @@ -65,6 +69,12 @@ export class LabelProviderCore @attr({ attribute: 'loading' }) public loading: string | undefined; + @attr({ attribute: 'scroll-backward' }) + public scrollBackward: string | undefined; + + @attr({ attribute: 'scroll-forward' }) + public scrollForward: string | undefined; + protected override readonly supportedLabels = supportedLabels; } diff --git a/packages/nimble-components/src/label-provider/core/label-token-defaults.ts b/packages/nimble-components/src/label-provider/core/label-token-defaults.ts index 1db1cd99b6..799784b176 100644 --- a/packages/nimble-components/src/label-provider/core/label-token-defaults.ts +++ b/packages/nimble-components/src/label-provider/core/label-token-defaults.ts @@ -11,5 +11,7 @@ export const coreLabelDefaults: { readonly [key in TokenName]: string } = { popupIconInformationLabel: 'Information', filterSearchLabel: 'Search', filterNoResultsLabel: 'No items found', - loadingLabel: 'Loading…' + loadingLabel: 'Loading…', + scrollBackwardLabel: 'Scroll backward', + scrollForwardLabel: 'Scroll forward' }; diff --git a/packages/nimble-components/src/label-provider/core/label-tokens.ts b/packages/nimble-components/src/label-provider/core/label-tokens.ts index 6b8f4ceec9..c55717afb8 100644 --- a/packages/nimble-components/src/label-provider/core/label-tokens.ts +++ b/packages/nimble-components/src/label-provider/core/label-tokens.ts @@ -45,3 +45,13 @@ export const loadingLabel = DesignToken.create({ name: 'loading-label', cssCustomPropertyName: null }).withDefault(coreLabelDefaults.loadingLabel); + +export const scrollBackwardLabel = DesignToken.create({ + name: 'scroll-backward-label', + cssCustomPropertyName: null +}).withDefault(coreLabelDefaults.scrollBackwardLabel); + +export const scrollForwardLabel = DesignToken.create({ + name: 'scroll-forward-label', + cssCustomPropertyName: null +}).withDefault(coreLabelDefaults.scrollForwardLabel); diff --git a/packages/nimble-components/src/patterns/tabs/styles.ts b/packages/nimble-components/src/patterns/tabs/styles.ts new file mode 100644 index 0000000000..a274cf8247 --- /dev/null +++ b/packages/nimble-components/src/patterns/tabs/styles.ts @@ -0,0 +1,35 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '../../utilities/style/display'; +import { smallPadding } from '../../theme-provider/design-tokens'; + +export const styles = css` + ${display('flex')} + + :host { + flex-direction: column; + } + + .tab-bar { + display: flex; + } + + [part='start'] { + display: none; + } + + .scroll-button.left { + margin-right: ${smallPadding}; + } + + .tablist { + display: flex; + width: max-content; + align-self: end; + overflow-x: scroll; + scrollbar-width: none; + } + + .scroll-button.right { + margin-left: ${smallPadding}; + } +`; diff --git a/packages/nimble-components/src/patterns/tabs/template.ts b/packages/nimble-components/src/patterns/tabs/template.ts new file mode 100644 index 0000000000..4719279847 --- /dev/null +++ b/packages/nimble-components/src/patterns/tabs/template.ts @@ -0,0 +1,70 @@ +import { html, ref, slotted, when } from '@microsoft/fast-element'; +import type { ViewTemplate } from '@microsoft/fast-element'; +import { + endSlotTemplate, + startSlotTemplate, + FoundationElementTemplate, + TabsOptions +} from '@microsoft/fast-foundation'; +import type { Tabs } from '../../tabs'; +import { buttonTag } from '../../button'; +import { iconArrowExpanderLeftTag } from '../../icons/arrow-expander-left'; +import { iconArrowExpanderRightTag } from '../../icons/arrow-expander-right'; +import type { TabsOwner } from './types'; +import { ButtonAppearance } from '../button/types'; +import { + scrollForwardLabel, + scrollBackwardLabel +} from '../../label-provider/core/label-tokens'; + +// prettier-ignore +export const template: FoundationElementTemplate< +ViewTemplate, +TabsOptions +> = (context, definition) => html` +
+ ${startSlotTemplate(context, definition)} + ${when(x => x.showScrollButtons, html` + <${buttonTag} + content-hidden + class="scroll-button left" + appearance="${ButtonAppearance.ghost}" + tabindex="-1" + @click="${x => x.onScrollLeftClick()}" + ${ref('leftScrollButton')} + > + ${x => scrollForwardLabel.getValueFor(x)} + <${iconArrowExpanderLeftTag} slot="start"> + + `)} +
+ + +
+ ${when(x => x.showScrollButtons, html` + <${buttonTag} + content-hidden + class="scroll-button right" + appearance="${ButtonAppearance.ghost}" + tabindex="-1" + @click="${x => x.onScrollRightClick()}" + > + ${x => scrollBackwardLabel.getValueFor(x)} + <${iconArrowExpanderRightTag} slot="start"> + + `)} + ${endSlotTemplate(context, definition)} +
+ ${when(x => 'tabpanels' in x, html` +
+ +
+ `)} +`; diff --git a/packages/nimble-components/src/patterns/tabs/testing/tabs-base.pageobject.ts b/packages/nimble-components/src/patterns/tabs/testing/tabs-base.pageobject.ts new file mode 100644 index 0000000000..854aeac718 --- /dev/null +++ b/packages/nimble-components/src/patterns/tabs/testing/tabs-base.pageobject.ts @@ -0,0 +1,116 @@ +import type { Button } from '../../../button'; +import { waitForUpdatesAsync } from '../../../testing/async-helpers'; +import { waitTimeout } from '../../../utilities/testing/component'; +import type { TabsOwner } from '../types'; + +/** + * Page object for the `nimble-tabs` and `nimble-anchor-tabs` components to provide + * consistent ways of querying and interacting with the component during tests. + */ +export abstract class TabsBasePageObject { + public constructor( + protected readonly tabsElement: T, + protected readonly tabElementName: string, + protected readonly tabPanelElementName?: string + ) {} + + public async clickTab(index: number): Promise { + if (index >= this.tabsElement.tabs.length) { + throw new Error(`Tab with index ${index} not found`); + } + this.tabsElement.tabs[index]!.click(); + await waitForUpdatesAsync(); + } + + public async pressKeyOnTab(index: number, key: string): Promise { + if (index >= this.tabsElement.tabs.length) { + throw new Error(`Tab with index ${index} not found`); + } + const tab = this.tabsElement.tabs[index]!; + tab.dispatchEvent(new KeyboardEvent('keydown', { key })); + await waitForUpdatesAsync(); + } + + public async setTabsWidth(width: number): Promise { + this.tabsElement.style.width = `${width}px`; + await waitForUpdatesAsync(); + await waitForUpdatesAsync(); // wait for the resize observer to fire + } + + public async clickScrollLeftButton(): Promise { + const leftButton = this.tabsElement.shadowRoot!.querySelector