diff --git a/core/api.txt b/core/api.txt index f2af11afc9b..e70521bdcc6 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2617,7 +2617,6 @@ ion-tab-button,part,native ion-tabs,shadow ion-tabs,prop,mode,"ios" | "md",undefined,false,false -ion-tabs,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-tabs,method,getSelected,getSelected() => Promise ion-tabs,method,getTab,getTab(tab: string | HTMLIonTabElement) => Promise ion-tabs,method,select,select(tab: string | HTMLIonTabElement) => Promise diff --git a/core/src/components.d.ts b/core/src/components.d.ts index fb7e5f9cff6..d904fe427ef 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -4034,10 +4034,6 @@ export namespace Components { */ "select": (tab: string | HTMLIonTabElement) => Promise; "setRouteId": (id: string) => Promise; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; /** * @default false */ @@ -10049,10 +10045,6 @@ declare namespace LocalJSX { * Emitted when the navigation is about to transition to a new component. */ "onIonTabsWillChange"?: (event: IonTabsCustomEvent<{ tab: string }>) => void; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; /** * @default false */ diff --git a/core/src/components/tabs/tabs-interface.ts b/core/src/components/tabs/tabs.interfaces.ts similarity index 100% rename from core/src/components/tabs/tabs-interface.ts rename to core/src/components/tabs/tabs.interfaces.ts diff --git a/core/src/components/tabs/tabs.scss b/core/src/components/tabs/tabs.scss index 741f2c67c40..a653a2d9041 100644 --- a/core/src/components/tabs/tabs.scss +++ b/core/src/components/tabs/tabs.scss @@ -1,7 +1,7 @@ -@import "../../themes/native/native.globals"; +@use "../../themes/mixins" as mixins; :host { - @include position(0, 0, 0, 0); + @include mixins.position(0, 0, 0, 0); display: flex; position: absolute; @@ -12,7 +12,7 @@ height: 100%; contain: layout size style; - z-index: $z-index-page-container; + z-index: 0; } .tabs-inner { diff --git a/core/src/components/tabs/tabs.tsx b/core/src/components/tabs/tabs.tsx index fcb66642563..c4a4ae1206f 100644 --- a/core/src/components/tabs/tabs.tsx +++ b/core/src/components/tabs/tabs.tsx @@ -7,7 +7,6 @@ import type { TabButtonClickEventDetail } from '../tab-bar/tab-bar-interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. - * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. * * @slot - Content is placed between the named slots if provided without a slot. * @slot top - Content is placed at the top of the screen. @@ -82,7 +81,7 @@ export class Tabs implements NavOutlet { return; } - const tab = this.selectedTab ? this.selectedTab.tab : undefined; + const tab = this.selectedTab?.tab; // If tabs has no selected tab but tab-bar already has a selected-tab set, // don't overwrite it. This handles cases where tab-bar is used without ion-tab elements. @@ -130,7 +129,7 @@ export class Tabs implements NavOutlet { */ @Method() getSelected(): Promise { - return Promise.resolve(this.selectedTab ? this.selectedTab.tab : undefined); + return Promise.resolve(this.selectedTab?.tab); } /** @internal */ @@ -158,7 +157,7 @@ export class Tabs implements NavOutlet { private setActive(selectedTab: HTMLIonTabElement): Promise { if (this.transitioning) { - return Promise.reject('transitioning already happening'); + return Promise.reject(new Error('transitioning already happening')); } this.transitioning = true; @@ -190,10 +189,7 @@ export class Tabs implements NavOutlet { private notifyRouter() { if (this.useRouter) { - const router = document.querySelector('ion-router'); - if (router) { - return router.navChanged('forward'); - } + return this.router?.navChanged('forward'); } return Promise.resolve(false); } @@ -207,13 +203,14 @@ export class Tabs implements NavOutlet { return Array.from(this.el.querySelectorAll('ion-tab')); } + private get router() { + return document.querySelector('ion-router'); + } + private onTabClicked = (ev: CustomEvent) => { const { href, tab } = ev.detail; if (this.useRouter && href !== undefined) { - const router = document.querySelector('ion-router'); - if (router) { - router.push(href); - } + this.router?.push(href); } else { this.select(tab); } @@ -236,7 +233,7 @@ const getTab = (tabs: HTMLIonTabElement[], tab: string | HTMLIonTabElement): HTM const tabEl = typeof tab === 'string' ? tabs.find((t) => t.tab === tab) : tab; if (!tabEl) { - printIonError(`[ion-tabs] - Tab with id: "${tabEl}" does not exist`); + printIonError(`[ion-tabs] - Tab with id: "${tab}" does not exist`); } return tabEl; }; diff --git a/core/src/components/tabs/test/basic/index.html b/core/src/components/tabs/test/basic/index.html index 60e72662f83..36f462b145a 100644 --- a/core/src/components/tabs/test/basic/index.html +++ b/core/src/components/tabs/test/basic/index.html @@ -42,8 +42,6 @@

Tab One

- - @@ -129,7 +127,7 @@

Hidden Tab

- Page Four +

Page Four

`; } diff --git a/core/src/components/tabs/test/basic/tabs.e2e.ts b/core/src/components/tabs/test/basic/tabs.e2e.ts index 65e487784ba..0e77d393999 100644 --- a/core/src/components/tabs/test/basic/tabs.e2e.ts +++ b/core/src/components/tabs/test/basic/tabs.e2e.ts @@ -1,6 +1,9 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; +/** + * This behavior does not vary across modes/directions + */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('tabs: basic'), () => { test('should show correct tab when clicking the tab button', async ({ page }) => { diff --git a/core/src/components/tabs/test/placements/index.html b/core/src/components/tabs/test/placements/index.html index f4de8adaf9c..59c0ec75053 100644 --- a/core/src/components/tabs/test/placements/index.html +++ b/core/src/components/tabs/test/placements/index.html @@ -2,7 +2,7 @@ - Tab - Basic + Tab - Placements { test.describe(title('tabs: placement'), () => { test.beforeEach(async ({ page }) => { diff --git a/core/src/components/tabs/test/tabs.spec.ts b/core/src/components/tabs/test/tabs.spec.ts new file mode 100644 index 00000000000..4b0d4edbaab --- /dev/null +++ b/core/src/components/tabs/test/tabs.spec.ts @@ -0,0 +1,174 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Tab } from '../../tab/tab'; +import { Tabs } from '../tabs'; + +const HTML = ` + + + + +`; + +describe('ion-tabs', () => { + describe('getSelected()', () => { + it('should return the name of the initially selected tab', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + expect(await tabsEl.getSelected()).toBe('tab-one'); + }); + }); + + describe('getTab()', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // getTab() calls printIonError when the tab id is not found, suppress to keep test output clean + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should return the element for an existing tab id', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + const tabEl = page.body.querySelector('ion-tab[tab="tab-two"]')!; + expect(await tabsEl.getTab('tab-two')).toBe(tabEl); + }); + + it('should return undefined for a non-existent tab id', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + expect(await tabsEl.getTab('does-not-exist')).toBeUndefined(); + }); + }); + + describe('select()', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // select() calls printIonError when the tab id is not found, suppress to keep test output clean + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should switch to the specified tab and return true', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + const result = await tabsEl.select('tab-two'); + + expect(result).toBe(true); + expect(await tabsEl.getSelected()).toBe('tab-two'); + }); + + it('should return false when selecting the already active tab', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + const result = await tabsEl.select('tab-one'); + + expect(result).toBe(false); + }); + + it('should return false when selecting a non-existent tab id', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + const result = await tabsEl.select('does-not-exist'); + + expect(result).toBe(false); + }); + }); + + describe('events', () => { + it('should emit ionTabsWillChange and ionTabsDidChange when switching tabs', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + + const willChangeSpy = jest.fn(); + const didChangeSpy = jest.fn(); + tabsEl.addEventListener('ionTabsWillChange', willChangeSpy); + tabsEl.addEventListener('ionTabsDidChange', didChangeSpy); + + await tabsEl.select('tab-two'); + await page.waitForChanges(); + + expect(willChangeSpy).toHaveBeenCalledWith(expect.objectContaining({ detail: { tab: 'tab-two' } })); + expect(didChangeSpy).toHaveBeenCalledWith(expect.objectContaining({ detail: { tab: 'tab-two' } })); + }); + + it('should not emit ionTabsDidChange when selecting the already active tab', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + + const didChangeSpy = jest.fn(); + tabsEl.addEventListener('ionTabsDidChange', didChangeSpy); + + await tabsEl.select('tab-one'); + + expect(didChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // select() calls printIonError when the tab id is not found, suppress to keep test output clean + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should log the correct tab id when selecting a non-existent tab', async () => { + const page = await newSpecPage({ + components: [Tabs, Tab], + html: HTML, + }); + + const tabsEl = page.body.querySelector('ion-tabs')!; + await tabsEl.select('does-not-exist'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Ionic Error]: [ion-tabs] - Tab with id: "does-not-exist" does not exist' + ); + }); + }); +}); diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index 158b8ad1c19..8d20cd2e265 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -33,7 +33,7 @@ export { export { SearchbarCustomEvent } from './components/searchbar/searchbar-interface'; export { SegmentCustomEvent } from './components/segment/segment-interface'; export { SelectCustomEvent, SelectCompareFn } from './components/select/select-interface'; -export { TabsCustomEvent } from './components/tabs/tabs-interface'; +export { TabsCustomEvent } from './components/tabs/tabs.interfaces'; export { TextareaCustomEvent } from './components/textarea/textarea-interface'; export { ToastOptions } from './components/toast/toast-interface'; export { ToggleCustomEvent } from './components/toggle/toggle-interface';