From 90cced13abf8f9e0bd83251c8357096e7b911596 Mon Sep 17 00:00:00 2001 From: Dimitri Kreik Date: Fri, 16 Oct 2026 09:51:41 +0200 Subject: [PATCH] feat(tab-bar): add fade scroll effect Add `scrollEffect="fade"` to ion-tab-bar. The tab bar background fades out when the content is scrolled to the end, like the footer. A tab bar inside ion-tabs now follows the content of the page that is visible, instead of the first content in the app. Previously, `scrollEffect="hide"` only worked for the first tab and did not work on pages pushed inside of a tab, or on the first page when it was loaded directly in Vue. The scroll effect of a tab bar in ion-tabs only applies to pages that cannot go back by default, so that the tab bar stays in place on pages that were navigated to. Add `tabBarScrollEffect` to ion-content to turn it on or off for a page. Change how `scrollEffect="hide"` moves the header, footer, and tab bar. They follow the scroll pixel by pixel instead of animating after a threshold. A bar that is fully out of view only returns when the user scrolls back quickly, so that slow scrolling and small movements do not bring it back. A bar that is partly out of view follows the scroll back at any speed, and settles to the closest edge when the scrolling stops. The header and footer fade out as they move out of view, so they do not stay visible in front of a tab bar that is out of view. The content extends under the bars, so hiding a bar no longer leaves a gap, including when a header and a tab bar hide together. A header, footer, or tab bar that is out of view is no longer inert or hidden from assistive technology. It moves back into view when an element in it receives focus, so keyboard and screen reader users can reach it without scrolling back quickly. A header that hides on scroll no longer takes part in the large title collapse. The large title scrolls away with the content instead of disappearing when the header is already out of view. References #26510 --- core/api.txt | 3 +- core/src/components.d.ts | 23 +- core/src/components/content/content.scss | 64 +--- core/src/components/content/content.tsx | 22 ++ core/src/components/footer/footer.scss | 21 +- core/src/components/footer/footer.tsx | 5 +- .../test/scroll-effect-hide/footer.e2e.ts | 97 ++++- core/src/components/header/header.common.scss | 31 +- core/src/components/header/header.tsx | 15 +- .../scroll-effect-hide-condense/header.e2e.ts | 28 ++ .../scroll-effect-hide-condense/index.html | 39 ++ .../test/scroll-effect-hide/header.e2e.ts | 97 ++++- .../components/tab-bar/tab-bar-interface.ts | 2 +- .../components/tab-bar/tab-bar.common.scss | 64 +++- .../src/components/tab-bar/tab-bar.ionic.scss | 25 +- core/src/components/tab-bar/tab-bar.ios.scss | 9 + .../components/tab-bar/tab-bar.native.scss | 11 + core/src/components/tab-bar/tab-bar.tsx | 193 ++++++++-- .../test/scroll-effect-fade/index.html | 44 +++ .../test/scroll-effect-fade/tab-bar.e2e.ts | 58 +++ .../test/scroll-effect-hide-tabs/index.html | 117 ++++++ .../scroll-effect-hide-tabs/tab-bar.e2e.ts | 195 ++++++++++ .../test/scroll-effect-hide/tab-bar.e2e.ts | 159 +++++++- core/src/utils/content/index.ts | 65 ++++ core/src/utils/scroll-hide-controller.ts | 349 ++++++++++++------ .../angular/src/lazy/directives/proxies.ts | 4 +- .../src/standalone/directives/ion-content.ts | 4 +- .../standalone/tab-bar-scroll-effect.spec.ts | 119 ++++++ .../standalone/app-standalone/app.routes.ts | 11 + .../home-page/home-page.component.html | 5 + .../tab-bar-scroll-effect-child.component.ts | 25 ++ .../tab-bar-scroll-effect-tab1.component.ts | 24 ++ .../tab-bar-scroll-effect-tab2.component.ts | 19 + .../tab-bar-scroll-effect.component.ts | 32 ++ packages/react/test/base/src/App.tsx | 2 + .../test/base/src/pages/TabsScrollEffect.tsx | 100 +++++ .../test/base/tests/e2e/specs/tabs/tabs.cy.ts | 97 +++++ packages/vue/src/proxies.ts | 3 + packages/vue/test/base/src/router/index.ts | 26 ++ .../src/views/tab-bar-scroll-effect/Child.vue | 52 +++ .../tab-bar-scroll-effect/ChildEffect.vue | 52 +++ .../src/views/tab-bar-scroll-effect/Home.vue | 52 +++ .../src/views/tab-bar-scroll-effect/Other.vue | 43 +++ .../TabBarScrollEffect.vue | 50 +++ .../playwright/tab-bar-scroll-effect.spec.ts | 114 ++++++ 45 files changed, 2315 insertions(+), 255 deletions(-) create mode 100644 core/src/components/header/test/scroll-effect-hide-condense/header.e2e.ts create mode 100644 core/src/components/header/test/scroll-effect-hide-condense/index.html create mode 100644 core/src/components/tab-bar/test/scroll-effect-fade/index.html create mode 100644 core/src/components/tab-bar/test/scroll-effect-fade/tab-bar.e2e.ts create mode 100644 core/src/components/tab-bar/test/scroll-effect-hide-tabs/index.html create mode 100644 core/src/components/tab-bar/test/scroll-effect-hide-tabs/tab-bar.e2e.ts create mode 100644 packages/angular/test/base/e2e/src/standalone/tab-bar-scroll-effect.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-child.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab1.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab2.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect.component.ts create mode 100644 packages/react/test/base/src/pages/TabsScrollEffect.tsx create mode 100644 packages/vue/test/base/src/views/tab-bar-scroll-effect/Child.vue create mode 100644 packages/vue/test/base/src/views/tab-bar-scroll-effect/ChildEffect.vue create mode 100644 packages/vue/test/base/src/views/tab-bar-scroll-effect/Home.vue create mode 100644 packages/vue/test/base/src/views/tab-bar-scroll-effect/Other.vue create mode 100644 packages/vue/test/base/src/views/tab-bar-scroll-effect/TabBarScrollEffect.vue create mode 100644 packages/vue/test/base/tests/e2e/playwright/tab-bar-scroll-effect.spec.ts diff --git a/core/api.txt b/core/api.txt index 5d12fba32c3..4f5f24aee70 100644 --- a/core/api.txt +++ b/core/api.txt @@ -666,6 +666,7 @@ ion-content,prop,mode,"ios" | "md",undefined,false,false ion-content,prop,scrollEvents,boolean,false,false,false ion-content,prop,scrollX,boolean,false,false,false ion-content,prop,scrollY,boolean,true,false,false +ion-content,prop,tabBarScrollEffect,boolean | undefined,undefined,false,false ion-content,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-content,method,getScrollElement,getScrollElement() => Promise ion-content,method,scrollByPoint,scrollByPoint(x: number, y: number, duration: number) => Promise @@ -2397,7 +2398,7 @@ ion-tab-bar,shadow ion-tab-bar,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true ion-tab-bar,prop,expand,"compact" | "full",'full',false,false ion-tab-bar,prop,mode,"ios" | "md",undefined,false,false -ion-tab-bar,prop,scrollEffect,"hide" | undefined,undefined,false,true +ion-tab-bar,prop,scrollEffect,"fade" | "hide" | undefined,undefined,false,true ion-tab-bar,prop,selectedTab,string | undefined,undefined,false,false ion-tab-bar,prop,shape,"rectangular" | "round" | "soft" | undefined,undefined,false,false ion-tab-bar,prop,theme,"ios" | "md" | "ionic",undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ebfdb615049..7ded2b16e18 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1118,6 +1118,10 @@ export namespace Components { * @default true */ "scrollY": boolean; + /** + * Whether the scroll effect of an `ion-tab-bar` applies to this content. By default, the scroll effect only applies to the content of a page that cannot go back, such as the root page of a tab, so that the tab bar stays in place on pages that were navigated to. Set to `true` to apply the scroll effect on a page that can go back, or to `false` to turn it off for a page. Only applies to a tab bar that has a `scrollEffect` and is a child of `ion-tabs`. + */ + "tabBarScrollEffect"?: boolean; /** * The theme determines the visual appearance of the component. */ @@ -1471,7 +1475,7 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * Describes the scroll effect that will be applied to the footer. `"hide"` slides the footer out of view when scrolling down and back in when scrolling up. `"fade"` fades the toolbar background on scroll. + * Describes the scroll effect that will be applied to the footer. `"hide"` slides the footer out of view when scrolling down and back in when scrolling back quickly. A footer that is out of view is shown again when an element in it receives focus. `"fade"` fades the toolbar background on scroll. */ "scrollEffect"?: FooterScrollEffect; /** @@ -1558,7 +1562,7 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * Describes the scroll effect that will be applied to the header. `"hide"` slides the header out of view when scrolling down and back in when scrolling up. `"condense"` collapses the large title into the main toolbar on scroll. `"fade"` fades the toolbar background on scroll. + * Describes the scroll effect that will be applied to the header. `"hide"` slides the header out of view when scrolling down and back in when scrolling back quickly. A header that is out of view is shown again when an element in it receives focus. `"condense"` collapses the large title into the main toolbar on scroll. `"fade"` fades the toolbar background on scroll. */ "scrollEffect"?: HeaderScrollEffect; /** @@ -3947,7 +3951,7 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * Describes the scroll effect that will be applied to the tab bar. `"hide"` slides the tab bar out of view when scrolling down and back in when scrolling up. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. + * Describes the scroll effect that will be applied to the tab bar. `"hide"` slides the tab bar out of view when scrolling down and back in when scrolling back quickly. A tab bar that is out of view is shown again when an element in it receives focus. `"fade"` fades the tab bar background out when the content is scrolled to the end. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. */ "scrollEffect"?: TabBarScrollEffect; /** @@ -4869,6 +4873,7 @@ declare global { new (): HTMLIonColElement; }; interface HTMLIonContentElementEventMap { + "ionTabBarScrollEffectChange": void; "ionScrollStart": ScrollBaseDetail; "ionScroll": ScrollDetail; "ionScrollEnd": ScrollBaseDetail; @@ -7021,6 +7026,7 @@ declare namespace LocalJSX { * Emitted when the scroll has started. This event is disabled by default. Set `scrollEvents` to `true` to enable. */ "onIonScrollStart"?: (event: IonContentCustomEvent) => void; + "onIonTabBarScrollEffectChange"?: (event: IonContentCustomEvent) => void; /** * Because of performance reasons, ionScroll events are disabled by default, in order to enable them and start listening from (ionScroll), set this property to `true`. * @default false @@ -7036,6 +7042,10 @@ declare namespace LocalJSX { * @default true */ "scrollY"?: boolean; + /** + * Whether the scroll effect of an `ion-tab-bar` applies to this content. By default, the scroll effect only applies to the content of a page that cannot go back, such as the root page of a tab, so that the tab bar stays in place on pages that were navigated to. Set to `true` to apply the scroll effect on a page that can go back, or to `false` to turn it off for a page. Only applies to a tab bar that has a `scrollEffect` and is a child of `ion-tabs`. + */ + "tabBarScrollEffect"?: boolean; /** * The theme determines the visual appearance of the component. */ @@ -7398,7 +7408,7 @@ declare namespace LocalJSX { */ "mode"?: "ios" | "md"; /** - * Describes the scroll effect that will be applied to the footer. `"hide"` slides the footer out of view when scrolling down and back in when scrolling up. `"fade"` fades the toolbar background on scroll. + * Describes the scroll effect that will be applied to the footer. `"hide"` slides the footer out of view when scrolling down and back in when scrolling back quickly. A footer that is out of view is shown again when an element in it receives focus. `"fade"` fades the toolbar background on scroll. */ "scrollEffect"?: FooterScrollEffect; /** @@ -7481,7 +7491,7 @@ declare namespace LocalJSX { */ "mode"?: "ios" | "md"; /** - * Describes the scroll effect that will be applied to the header. `"hide"` slides the header out of view when scrolling down and back in when scrolling up. `"condense"` collapses the large title into the main toolbar on scroll. `"fade"` fades the toolbar background on scroll. + * Describes the scroll effect that will be applied to the header. `"hide"` slides the header out of view when scrolling down and back in when scrolling back quickly. A header that is out of view is shown again when an element in it receives focus. `"condense"` collapses the large title into the main toolbar on scroll. `"fade"` fades the toolbar background on scroll. */ "scrollEffect"?: HeaderScrollEffect; /** @@ -9966,7 +9976,7 @@ declare namespace LocalJSX { "onIonTabBarChanged"?: (event: IonTabBarCustomEvent) => void; "onIonTabBarLoaded"?: (event: IonTabBarCustomEvent) => void; /** - * Describes the scroll effect that will be applied to the tab bar. `"hide"` slides the tab bar out of view when scrolling down and back in when scrolling up. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. + * Describes the scroll effect that will be applied to the tab bar. `"hide"` slides the tab bar out of view when scrolling down and back in when scrolling back quickly. A tab bar that is out of view is shown again when an element in it receives focus. `"fade"` fades the tab bar background out when the content is scrolled to the end. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. */ "scrollEffect"?: TabBarScrollEffect; /** @@ -10700,6 +10710,7 @@ declare namespace LocalJSX { "scrollX": boolean; "scrollY": boolean; "scrollEvents": boolean; + "tabBarScrollEffect": boolean; } interface IonDatetimeAttributes { "color": Color; diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index 2493a39a125..17c65938ab4 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -260,57 +260,29 @@ transform: translateZ(0); } -// Content: Header/Footer/Tab Bar Scroll Effect Hide — Gap Compensation +// Content: Header/Footer/Tab Bar Scroll Effect Hide // -------------------------------------------------- -// When a bar hides via scrollEffect="hide", it stays in the layout flow -// but is translated out of view. The inner-scroll must shift and expand -// to fill the gap so no empty space is visible to the user. - -// Header gap compensation — only when content is NOT fullscreen. -// When fullscreen=true, inner-scroll already extends behind the header -// (offset-top = header height), so translateY would over-shift the content. +// A header, footer, or tab bar that hides on scroll stays in the layout and +// moves out of view while the content scrolls. The scroll area extends under +// the bars and is padded by the same amount, so nothing is covered while the +// bars are visible, and the content fills the space of a bar that moved out of +// view without changing the layout while scrolling. +// The variables are 0 for bars that are not part of the layout. + +// Not needed when fullscreen=true, since the scroll area already extends +// behind the header and footer. :host(.content-header-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll { - transition: transform 300ms cubic-bezier(0, 0, 0.2, 1), height 300ms cubic-bezier(0, 0, 0.2, 1); -} - -:host(.content-header-hide-scroll-partner:not(.content-fullscreen).content-header-hide-scroll-hidden) .inner-scroll { - height: calc(100% + var(--internal-header-hide-height, 0px)); - - transform: translateY(calc(-1 * var(--internal-header-hide-height, 0px))); - - transition: transform 200ms cubic-bezier(0.4, 0, 1, 1), height 200ms cubic-bezier(0.4, 0, 1, 1); -} - -// Footer gap compensation — only when content is NOT fullscreen -:host(.content-footer-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll { - transition: height 200ms cubic-bezier(0.16, 1, 0.3, 1); -} - -:host(.content-footer-hide-scroll-partner:not(.content-fullscreen).content-footer-hide-scroll-hidden) .inner-scroll { - height: calc(100% + var(--internal-footer-hide-height, 0px)); + top: calc(var(--internal-header-hide-height, 0px) * -1); - transition: height 350ms cubic-bezier(0.4, 0, 1, 1); + padding-top: calc(var(--padding-top) + var(--internal-header-hide-height, 0px)); } -// Tab bar gap compensation — only when content is NOT fullscreen +:host(.content-footer-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll, :host(.content-tab-bar-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll { - transition: height 200ms cubic-bezier(0.16, 1, 0.3, 1); -} - -:host(.content-tab-bar-hide-scroll-partner:not(.content-fullscreen).content-tab-bar-hide-scroll-hidden) .inner-scroll { - height: calc(100% + var(--internal-tab-bar-hide-height, 0px)); + bottom: calc((var(--internal-footer-hide-height, 0px) + var(--internal-tab-bar-hide-height, 0px)) * -1); - transition: height 350ms cubic-bezier(0.4, 0, 1, 1); -} - -@media (prefers-reduced-motion: reduce) { - :host(.content-header-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll, - :host(.content-header-hide-scroll-partner:not(.content-fullscreen).content-header-hide-scroll-hidden) .inner-scroll, - :host(.content-footer-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll, - :host(.content-footer-hide-scroll-partner:not(.content-fullscreen).content-footer-hide-scroll-hidden) .inner-scroll, - :host(.content-tab-bar-hide-scroll-partner:not(.content-fullscreen)) .inner-scroll, - :host(.content-tab-bar-hide-scroll-partner:not(.content-fullscreen).content-tab-bar-hide-scroll-hidden) - .inner-scroll { - transition: none; - } + padding-bottom: calc( + var(--padding-bottom) + var(--keyboard-offset) + var(--internal-content-safe-area-padding-bottom, 0px) + + var(--internal-footer-hide-height, 0px) + var(--internal-tab-bar-hide-height, 0px) + ); } diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index 4e72ecc263a..43e1b57b0ba 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -139,6 +139,28 @@ export class Content implements ComponentInterface { */ @Prop() scrollEvents = false; + /** + * Whether the scroll effect of an `ion-tab-bar` applies to this content. + * By default, the scroll effect only applies to the content of a page that + * cannot go back, such as the root page of a tab, so that the tab bar stays + * in place on pages that were navigated to. + * Set to `true` to apply the scroll effect on a page that can go back, or to + * `false` to turn it off for a page. + * Only applies to a tab bar that has a `scrollEffect` and is a child of `ion-tabs`. + */ + @Prop() tabBarScrollEffect?: boolean; + @Watch('tabBarScrollEffect') + tabBarScrollEffectChanged() { + this.ionTabBarScrollEffectChange.emit(); + } + + /** + * @internal + * Emitted when `tabBarScrollEffect` changes, so that the tab bar + * can update which pages it applies its scroll effect to. + */ + @Event() ionTabBarScrollEffectChange!: EventEmitter; + /** * Emitted when the scroll has started. This event is disabled by default. * Set `scrollEvents` to `true` to enable. diff --git a/core/src/components/footer/footer.scss b/core/src/components/footer/footer.scss index 92d791e6af1..b997fa640ca 100644 --- a/core/src/components/footer/footer.scss +++ b/core/src/components/footer/footer.scss @@ -28,23 +28,22 @@ ion-footer.footer-toolbar-padding ion-toolbar:last-of-type { // Footer - Scroll Effect Hide // -------------------------------------------------- -// Slide back into view when scrolling up — spring ease-out snaps the footer -// back into place quickly and settles smoothly. +// The footer follows the scroll pixel by pixel. The offset is set +// by the scroll effect and is how far the footer is out of view. ion-footer.footer-scroll-effect-hide { - transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); -} + transform: translateY(var(--internal-scroll-hide-offset, 0px)); -// Slide out of view when scrolling down — footer moves off the bottom of the -// screen. Slower duration mirrors the natural pace of the downward scroll. -ion-footer.footer-scroll-effect-hide.footer-scroll-hidden { - transform: translateY(100%); + // Fades out so that it does not stay in view when it comes to rest above another bar + opacity: calc(1 - var(--internal-scroll-hide-progress, 0)); +} - transition: transform 350ms cubic-bezier(0.4, 0, 1, 1); +// Only animate when the footer settles after scrolling stops. +ion-footer.footer-scroll-effect-hide.footer-scroll-settling { + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); } @media (prefers-reduced-motion: reduce) { - ion-footer.footer-scroll-effect-hide, - ion-footer.footer-scroll-effect-hide.footer-scroll-hidden { + ion-footer.footer-scroll-effect-hide.footer-scroll-settling { transition: none; } } diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index d7762dc6729..a7c486cc97d 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -43,7 +43,8 @@ export class Footer implements ComponentInterface { /** * Describes the scroll effect that will be applied to the footer. * `"hide"` slides the footer out of view when scrolling down and back in - * when scrolling up. + * when scrolling back quickly. A footer that is out of view is shown again + * when an element in it receives focus. * `"fade"` fades the toolbar background on scroll. */ @Prop({ reflect: true }) scrollEffect?: FooterScrollEffect; @@ -171,8 +172,10 @@ export class Footer implements ComponentInterface { private setupScrollEffectHide = async (contentEl: HTMLElement) => { const setupPromise = createScrollHideController(contentEl, { el: this.el, + edge: 'bottom', cssVar: '--internal-footer-hide-height', hiddenClass: 'footer-scroll-hidden', + settlingClass: 'footer-scroll-settling', contentPartnerClass: 'content-footer-hide-scroll-partner', contentHiddenClass: 'content-footer-hide-scroll-hidden', }); diff --git a/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts b/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts index 751307ea5f7..d4376b639de 100644 --- a/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts +++ b/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts @@ -22,7 +22,9 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await page.locator('ion-footer.footer-scroll-hidden').waitFor(); await expect(footer).toHaveClass(/footer-scroll-hidden/); - await expect(footer).toHaveAttribute('inert', ''); + // A footer that is out of view stays reachable for keyboard and screen reader users + await expect(footer).not.toHaveAttribute('inert'); + await expect(footer).not.toHaveAttribute('aria-hidden'); // Verify the footer is visually off-screen via transform const transform = await footer.evaluate((el) => getComputedStyle(el).transform); @@ -44,5 +46,98 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await expect(footer).not.toHaveClass(/footer-scroll-hidden/); await expect(footer).not.toHaveAttribute('inert'); }); + test('should move out of view with the scroll, pixel by pixel', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-hide', config); + + const footer = page.locator('ion-footer'); + const content = page.locator('ion-content'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + + for (let i = 0; i < 4; i++) { + scrollEl.scrollTop += 5; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + // Scrolling 20px moves the footer 20px out of view + await expect.poll(() => footer.evaluate((el) => new DOMMatrix(getComputedStyle(el).transform).m42)).toBe(20); + }); + test('should fade out while it moves out of view', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-hide', config); + + const footer = page.locator('ion-footer'); + const content = page.locator('ion-content'); + + await expect(footer).toHaveCSS('opacity', '1'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-footer.footer-scroll-hidden').waitFor(); + + // It could come to rest in front of another bar, such as a tab bar, so it cannot stay visible + await expect(footer).toHaveCSS('opacity', '0'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + await page.locator('ion-footer:not(.footer-scroll-hidden)').waitFor(); + + await expect(footer).toHaveCSS('opacity', '1'); + }); + test('should show again when an element in it receives focus', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-hide', config); + + const footer = page.locator('ion-footer'); + const content = page.locator('ion-content'); + + // The footer needs an element that can be focused + await footer.evaluate((el) => { + const toolbar = el.querySelector('ion-toolbar')!; + toolbar.insertAdjacentHTML( + 'beforeend', + 'Action' + ); + }); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-footer.footer-scroll-hidden').waitFor(); + + // Keyboard users cannot scroll back quickly, so focus has to bring it back + await page.locator('ion-footer ion-button button').first().focus(); + + await expect(footer).not.toHaveClass(/footer-scroll-hidden/); + await expect + .poll(() => footer.evaluate((el) => el.style.getPropertyValue('--internal-scroll-hide-offset'))) + .toBe('0px'); + }); + + test('should not show again when scrolling back slowly while an element in it is not focused', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-hide', config); + + const footer = page.locator('ion-footer'); + const content = page.locator('ion-content'); + + // The footer needs an element that can be focused + await footer.evaluate((el) => { + const toolbar = el.querySelector('ion-toolbar')!; + toolbar.insertAdjacentHTML( + 'beforeend', + 'Action' + ); + }); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-footer.footer-scroll-hidden').waitFor(); + + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + + for (let i = 0; i < 20; i++) { + scrollEl.scrollTop -= 2; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + await expect(footer).toHaveClass(/footer-scroll-hidden/); + }); }); }); diff --git a/core/src/components/header/header.common.scss b/core/src/components/header/header.common.scss index 6170547ec53..51c6640a7c1 100644 --- a/core/src/components/header/header.common.scss +++ b/core/src/components/header/header.common.scss @@ -144,8 +144,14 @@ ion-header ion-toolbar:first-of-type { * added when the condense effect is valid (large title present). * The attribute alone is not sufficient since it would match even * when the effect is guarded (e.g. no large title). + * + * A main header that hides on scroll is excluded since the large + * title does not collapse into it, so it never gets the + * ".header-collapse-main" class. */ -ion-header:not(.header-collapse-main):has(~ ion-content ion-header.header-collapse-condense) { +ion-header:not(.header-collapse-main):not(.header-scroll-effect-hide):has( + ~ ion-content ion-header.header-collapse-condense + ) { opacity: 0; } @@ -163,25 +169,22 @@ ion-header:not(.header-collapse-main):has(~ ion-content ion-header.header-collap // Header - Scroll Effect Hide // -------------------------------------------------- -// Slide back into view when scrolling up — smooth deceleration eases the -// header back into place. +// The header follows the scroll pixel by pixel. The offset is set +// by the scroll effect and is how far the header is out of view. ion-header.header-scroll-effect-hide { - transition: transform 300ms cubic-bezier(0, 0, 0.2, 1), opacity 300ms cubic-bezier(0, 0, 0.2, 1); -} - -// Slide out of view when scrolling down — header moves off the top of the -// screen and fades out. Faster than the return to feel responsive to scroll. -ion-header.header-scroll-effect-hide.header-scroll-hidden { - transform: translateY(-100%); + transform: translateY(calc(var(--internal-scroll-hide-offset, 0px) * -1)); - transition: transform 200ms cubic-bezier(0.4, 0, 1, 1), opacity 300ms cubic-bezier(0.4, 0, 1, 1); + // Fades out so that it does not stay in view when it comes to rest below another bar + opacity: calc(1 - var(--internal-scroll-hide-progress, 0)); +} - opacity: 0; +// Only animate when the header settles after scrolling stops. +ion-header.header-scroll-effect-hide.header-scroll-settling { + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); } @media (prefers-reduced-motion: reduce) { - ion-header.header-scroll-effect-hide, - ion-header.header-scroll-effect-hide.header-scroll-hidden { + ion-header.header-scroll-effect-hide.header-scroll-settling { transition: none; } } diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index 9fb48f3bcc2..e6854d7829e 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -52,7 +52,8 @@ export class Header implements ComponentInterface { /** * Describes the scroll effect that will be applied to the header. * `"hide"` slides the header out of view when scrolling down and back in - * when scrolling up. + * when scrolling back quickly. A header that is out of view is shown again + * when an element in it receives focus. * `"condense"` collapses the large title into the main toolbar on scroll. * `"fade"` fades the toolbar background on scroll. */ @@ -170,8 +171,10 @@ export class Header implements ComponentInterface { private setupScrollEffectHide = async (contentEl: HTMLElement) => { const setupPromise = createScrollHideController(contentEl, { el: this.el, + edge: 'top', cssVar: '--internal-header-hide-height', hiddenClass: 'header-scroll-hidden', + settlingClass: 'header-scroll-settling', contentPartnerClass: 'content-header-hide-scroll-partner', contentHiddenClass: 'content-header-hide-scroll-hidden', }); @@ -277,6 +280,16 @@ export class Header implements ComponentInterface { return false; } + /** + * A main header that hides on scroll leaves the screen along with the + * scroll, so the large title cannot collapse into it. The large title + * scrolls with the content instead and the main header keeps its title. + */ + if ((this.collapsibleMainHeader as HTMLIonHeaderElement).scrollEffect === 'hide') { + this.collapsibleMainHeader = undefined; + return false; + } + const mainHeaderIndex = createHeaderIndex(this.collapsibleMainHeader); const scrollHeaderIndex = createHeaderIndex(this.el); diff --git a/core/src/components/header/test/scroll-effect-hide-condense/header.e2e.ts b/core/src/components/header/test/scroll-effect-hide-condense/header.e2e.ts new file mode 100644 index 00000000000..7cee1a64d9e --- /dev/null +++ b/core/src/components/header/test/scroll-effect-hide-condense/header.e2e.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('header: scroll-effect-hide with a large title'), () => { + test('should show the main header and its title before scrolling', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide-condense', config); + + await expect(page.locator('#mainHeader')).toHaveCSS('opacity', '1'); + await expect(page.locator('#mainHeader ion-title')).toHaveCSS('opacity', '1'); + }); + + test('should not collapse the large title into the header that hides', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide-condense', config); + + const largeTitleHeader = page.locator('#largeTitleHeader'); + const content = page.locator('ion-content'); + + await expect(content).toHaveClass(/content-header-hide-scroll-partner/); + await content.evaluate((el: HTMLIonContentElement) => el.scrollToPoint(0, 100, 0)); + await page.locator('#mainHeader.header-scroll-hidden').waitFor(); + + // The large title scrolls away with the content instead of being replaced by the title in the header + await expect(largeTitleHeader).not.toHaveClass(/header-collapse-condense-inactive/); + await expect(largeTitleHeader.locator('ion-title')).toHaveCSS('visibility', 'visible'); + }); + }); +}); diff --git a/core/src/components/header/test/scroll-effect-hide-condense/index.html b/core/src/components/header/test/scroll-effect-hide-condense/index.html new file mode 100644 index 00000000000..f43b1a7f95a --- /dev/null +++ b/core/src/components/header/test/scroll-effect-hide-condense/index.html @@ -0,0 +1,39 @@ + + + + + Header - Scroll Effect Hide with Large Title + + + + + + + + + + +
+ + + Header + + + + + + Header + + +
+

Content

+

Scroll down. The header hides, and the large title scrolls away with the content.

+
+
+
+
+ + diff --git a/core/src/components/header/test/scroll-effect-hide/header.e2e.ts b/core/src/components/header/test/scroll-effect-hide/header.e2e.ts index 32b89fb469c..efcb8f2fb9c 100644 --- a/core/src/components/header/test/scroll-effect-hide/header.e2e.ts +++ b/core/src/components/header/test/scroll-effect-hide/header.e2e.ts @@ -22,7 +22,9 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await page.locator('ion-header.header-scroll-hidden').waitFor(); await expect(header).toHaveClass(/header-scroll-hidden/); - await expect(header).toHaveAttribute('inert', ''); + // A header that is out of view stays reachable for keyboard and screen reader users + await expect(header).not.toHaveAttribute('inert'); + await expect(header).not.toHaveAttribute('aria-hidden'); // Verify the header is visually off-screen via transform const transform = await header.evaluate((el) => getComputedStyle(el).transform); @@ -44,5 +46,98 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await expect(header).not.toHaveClass(/header-scroll-hidden/); await expect(header).not.toHaveAttribute('inert'); }); + test('should move out of view with the scroll, pixel by pixel', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide', config); + + const header = page.locator('ion-header'); + const content = page.locator('ion-content'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + + for (let i = 0; i < 4; i++) { + scrollEl.scrollTop += 5; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + // Scrolling 20px moves the header 20px out of view + await expect.poll(() => header.evaluate((el) => new DOMMatrix(getComputedStyle(el).transform).m42)).toBe(-20); + }); + test('should fade out while it moves out of view', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide', config); + + const header = page.locator('ion-header'); + const content = page.locator('ion-content'); + + await expect(header).toHaveCSS('opacity', '1'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-header.header-scroll-hidden').waitFor(); + + // It could come to rest in front of another bar, such as a tab bar, so it cannot stay visible + await expect(header).toHaveCSS('opacity', '0'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + await page.locator('ion-header:not(.header-scroll-hidden)').waitFor(); + + await expect(header).toHaveCSS('opacity', '1'); + }); + test('should show again when an element in it receives focus', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide', config); + + const header = page.locator('ion-header'); + const content = page.locator('ion-content'); + + // The header needs an element that can be focused + await header.evaluate((el) => { + const toolbar = el.querySelector('ion-toolbar')!; + toolbar.insertAdjacentHTML( + 'beforeend', + 'Action' + ); + }); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-header.header-scroll-hidden').waitFor(); + + // Keyboard users cannot scroll back quickly, so focus has to bring it back + await page.locator('ion-header ion-button button').first().focus(); + + await expect(header).not.toHaveClass(/header-scroll-hidden/); + await expect + .poll(() => header.evaluate((el) => el.style.getPropertyValue('--internal-scroll-hide-offset'))) + .toBe('0px'); + }); + + test('should not show again when scrolling back slowly while an element in it is not focused', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide', config); + + const header = page.locator('ion-header'); + const content = page.locator('ion-content'); + + // The header needs an element that can be focused + await header.evaluate((el) => { + const toolbar = el.querySelector('ion-toolbar')!; + toolbar.insertAdjacentHTML( + 'beforeend', + 'Action' + ); + }); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-header.header-scroll-hidden').waitFor(); + + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + + for (let i = 0; i < 20; i++) { + scrollEl.scrollTop -= 2; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + await expect(header).toHaveClass(/header-scroll-hidden/); + }); }); }); diff --git a/core/src/components/tab-bar/tab-bar-interface.ts b/core/src/components/tab-bar/tab-bar-interface.ts index ba3a623350a..7b448b7390f 100644 --- a/core/src/components/tab-bar/tab-bar-interface.ts +++ b/core/src/components/tab-bar/tab-bar-interface.ts @@ -1,6 +1,6 @@ export type TabButtonLayout = 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide'; -export type TabBarScrollEffect = 'hide'; +export type TabBarScrollEffect = 'hide' | 'fade'; export interface TabBarChangedEventDetail { tab?: string; diff --git a/core/src/components/tab-bar/tab-bar.common.scss b/core/src/components/tab-bar/tab-bar.common.scss index 53ae31437bd..206880021d3 100644 --- a/core/src/components/tab-bar/tab-bar.common.scss +++ b/core/src/components/tab-bar/tab-bar.common.scss @@ -63,26 +63,70 @@ padding-bottom: 0; } +// Tab Bar - Scroll Effect Fade +// -------------------------------------------------- + +// The background and border are drawn by a child element so +// that they can fade without affecting the tab buttons. +:host(.tab-bar-scroll-effect-fade) { + position: relative; + + border-top: 0; + + background: transparent; + + isolation: isolate; +} + +:host([slot="top"].tab-bar-scroll-effect-fade) { + border-bottom: 0; +} + +// The tab buttons are transparent so that they do not cover the faded background. +:host(.tab-bar-scroll-effect-fade) ::slotted(ion-tab-button) { + background: transparent; +} + +.tab-bar-background { + position: absolute; + inset: 0; + + border-top: var(--border); + + background: var(--background); + + opacity: var(--opacity-scale, 1); + + pointer-events: none; + + z-index: -1; +} + +:host([slot="top"]) .tab-bar-background { + border-top: 0; + border-bottom: var(--border); +} + // Tab Bar - Scroll Effect Hide // -------------------------------------------------- -// Slide back into view when scrolling up — spring ease-out snaps the tab bar -// back into place quickly and settles smoothly. +// The tab bar follows the scroll pixel by pixel. The offset is set +// by the scroll effect and is how far the tab bar is out of view. :host(.tab-bar-scroll-effect-hide) { - transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); + transform: translateY(var(--internal-scroll-hide-offset, 0px)); } -// Slide out of view when scrolling down — tab bar moves off the bottom of the -// screen. Slower duration mirrors the natural pace of the downward scroll. -:host(.tab-bar-scroll-effect-hide.tab-bar-scroll-hidden) { - transform: translateY(100%); +:host([slot="top"].tab-bar-scroll-effect-hide) { + transform: translateY(calc(var(--internal-scroll-hide-offset, 0px) * -1)); +} - transition: transform 350ms cubic-bezier(0.4, 0, 1, 1); +// Only animate when the tab bar settles after scrolling stops. +:host(.tab-bar-scroll-effect-hide.tab-bar-scroll-settling) { + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); } @media (prefers-reduced-motion: reduce) { - :host(.tab-bar-scroll-effect-hide), - :host(.tab-bar-scroll-effect-hide.tab-bar-scroll-hidden) { + :host(.tab-bar-scroll-effect-hide.tab-bar-scroll-settling) { transition: none; } } diff --git a/core/src/components/tab-bar/tab-bar.ionic.scss b/core/src/components/tab-bar/tab-bar.ionic.scss index 5e0283019eb..0e1a49bd25b 100644 --- a/core/src/components/tab-bar/tab-bar.ionic.scss +++ b/core/src/components/tab-bar/tab-bar.ionic.scss @@ -92,18 +92,13 @@ // Tab Bar Hide on Scroll // -------------------------------------------------- -:host(.tab-bar-scroll-effect-hide) { - transition: transform globals.$ion-transition-time-200 globals.$ion-transition-curve-spring; +// The compact tab bar is centered with a translation, so the offset is added to it. +:host(.tab-bar-compact.tab-bar-scroll-effect-hide) { + transform: translate(-50%, var(--internal-scroll-hide-offset, 0px)); } -:host(.tab-bar-compact.tab-bar-scroll-hidden) { - transform: translateY(calc(100% + var(--ion-safe-area-bottom, 0) + globals.$ion-space-1000)) translateX(calc(-50%)); - - transition: transform globals.$ion-transition-time-300 globals.$ion-transition-curve-base; -} - -:host([slot="top"].tab-bar-compact.tab-bar-scroll-hidden) { - transform: translateY(-100%) translateX(calc(-50%)); +:host([slot="top"].tab-bar-compact.tab-bar-scroll-effect-hide) { + transform: translate(-50%, calc(var(--internal-scroll-hide-offset, 0px) * -1)); } // Tab Bar Translucent @@ -138,13 +133,3 @@ :host(.tab-bar-rectangular) { @include globals.border-radius(globals.$ion-border-radius-0); } - -// Reduced Motion -// -------------------------------------------------- - -@media (prefers-reduced-motion: reduce) { - :host(.tab-bar-scroll-effect-hide), - :host(.tab-bar-compact.tab-bar-scroll-hidden) { - transition: none; - } -} diff --git a/core/src/components/tab-bar/tab-bar.ios.scss b/core/src/components/tab-bar/tab-bar.ios.scss index 9b2769acf88..132b1eba038 100644 --- a/core/src/components/tab-bar/tab-bar.ios.scss +++ b/core/src/components/tab-bar/tab-bar.ios.scss @@ -32,4 +32,13 @@ :host(.tab-bar-translucent) ::slotted(ion-tab-button.ion-focused) { background: rgba($background-color-rgb, 0.6); } + + // The blur is applied to the faded background so that it fades with it + :host(.tab-bar-translucent.tab-bar-scroll-effect-fade) { + backdrop-filter: none; + } + + :host(.tab-bar-translucent.tab-bar-scroll-effect-fade) .tab-bar-background { + backdrop-filter: $tab-bar-ios-translucent-filter; + } } diff --git a/core/src/components/tab-bar/tab-bar.native.scss b/core/src/components/tab-bar/tab-bar.native.scss index f30da1f835e..00e67a903e1 100644 --- a/core/src/components/tab-bar/tab-bar.native.scss +++ b/core/src/components/tab-bar/tab-bar.native.scss @@ -23,3 +23,14 @@ :host(.ion-color) ::slotted(ion-tab-button) { background: #{current-color(base)}; } + +// Tab Bar with Color - Scroll Effect Fade +// -------------------------------------------------- + +:host(.ion-color.tab-bar-scroll-effect-fade) ::slotted(ion-tab-button) { + background: transparent; +} + +:host(.ion-color.tab-bar-scroll-effect-fade) .tab-bar-background { + background: #{current-color(base)}; +} diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index ba2be543b51..54469614a00 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; -import { findIonContent } from '@utils/content'; +import { findIonContent, findVisibleIonContent, getScrollElement, observeVisibleIonContent } from '@utils/content'; +import { componentOnReady } from '@utils/helpers'; import type { KeyboardController } from '@utils/keyboard/keyboard-controller'; import { createKeyboardController } from '@utils/keyboard/keyboard-controller'; import { printIonWarning } from '@utils/logging'; @@ -11,6 +12,7 @@ import { createColorClasses } from '@utils/theme'; import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; import type { Color } from '../../interface'; +import { handleFooterFade } from '../footer/footer.utils'; import type { TabBarChangedEventDetail, TabBarScrollEffect } from './tab-bar-interface'; @@ -34,6 +36,13 @@ export class TabBar implements ComponentInterface { private scrollHideCtrl?: ScrollHideController; private scrollHideCtrlPromise: Promise | null = null; private hasWarnedFooter = false; + private stopObservingContent?: () => void; + private fadeScrollEl?: HTMLElement; + private fadeScrollCallback?: () => void; + private fadeSetupId = 0; + private contentSetupId = 0; + private activeContentEl?: HTMLElement | null; + private isActiveContentEnabled = false; @Element() el!: HTMLElement; @@ -68,7 +77,10 @@ export class TabBar implements ComponentInterface { /** * Describes the scroll effect that will be applied to the tab bar. * `"hide"` slides the tab bar out of view when scrolling down and back in - * when scrolling up. + * when scrolling back quickly. A tab bar that is out of view is shown again + * when an element in it receives focus. + * `"fade"` fades the tab bar background out when the content is scrolled + * to the end. * * Note: `"hide"` is ignored when the tab bar is nested inside an * `ion-footer` to avoid leaving an empty footer visible on screen. @@ -134,9 +146,53 @@ export class TabBar implements ComponentInterface { private checkScrollEffect() { this.destroyScrollEffect(); - if (this.scrollEffect === 'hide') { - this.setupScrollEffect(); + + const { scrollEffect } = this; + + if (scrollEffect !== 'hide' && scrollEffect !== 'fade') { + return; + } + + // When nested inside ion-footer, the tab-bar should not hide + // independently — doing so would leave an empty footer behind. + // The footer should own the scroll effect instead. + if (scrollEffect === 'hide' && this.el.closest('ion-footer')) { + if (!this.hasWarnedFooter) { + this.hasWarnedFooter = true; + printIonWarning( + `[ion-tab-bar] - scroll-effect="hide" is ignored when nested inside . ` + + `Set scroll-effect="hide" on the instead.`, + this.el + ); + } + return; } + + /** + * A tab bar that is a direct child of ion-tabs is rendered outside of + * the pages that the tabs show, so it follows the content of whichever + * page is visible. Otherwise, it follows the content of the page it is in. + */ + const tabsEl = this.el.parentElement?.tagName === 'ION-TABS' ? this.el.parentElement : null; + + if (tabsEl !== null) { + const stopObserving = observeVisibleIonContent(tabsEl, (contentEl) => + this.setupScrollEffectForContent(contentEl, true) + ); + const onTabBarScrollEffectChange = () => this.setupScrollEffectForContent(findVisibleIonContent(tabsEl), true); + tabsEl.addEventListener('ionTabBarScrollEffectChange', onTabBarScrollEffectChange); + + this.stopObservingContent = () => { + stopObserving(); + tabsEl.removeEventListener('ionTabBarScrollEffectChange', onTabBarScrollEffectChange); + }; + return; + } + + const appRootSelector = config.get('appRootSelector', 'ion-app'); + const pageEl = this.el.closest(`${appRootSelector}, ion-page, .ion-page, page-inner`); + + this.setupScrollEffectForContent(pageEl ? findIonContent(pageEl) : null, false); } async connectedCallback() { @@ -152,13 +208,10 @@ export class TabBar implements ComponentInterface { this.keyboardVisible = keyboardOpen; // trigger re-render by updating state - // Toggle aria-hidden imperatively for the keyboard case. - // scroll-effect hide also sets aria-hidden via setHidden, - // so keeping a single imperative path avoids render() clearing - // the scroll-set attribute on re-render. + // Toggle aria-hidden imperatively for the keyboard case, + // so that render() does not clear it on re-render. const shouldHide = keyboardOpen && this.el.getAttribute('slot') !== 'top'; - const isScrollHidden = this.scrollHideCtrl?.isHidden() ?? false; - if (shouldHide || isScrollHidden) { + if (shouldHide) { this.el.setAttribute('aria-hidden', 'true'); } else { this.el.removeAttribute('aria-hidden'); @@ -195,37 +248,76 @@ export class TabBar implements ComponentInterface { } } - private setupScrollEffect = async () => { - // When nested inside ion-footer, the tab-bar should not hide - // independently — doing so would leave an empty footer behind. - // The footer should own the scroll effect instead. - if (this.el.closest('ion-footer')) { - if (!this.hasWarnedFooter) { - this.hasWarnedFooter = true; - printIonWarning( - `[ion-tab-bar] - scroll-effect="hide" is ignored when nested inside . ` + - `Set scroll-effect="hide" on the instead.`, - this.el - ); + /** + * Whether the scroll effect applies to the page with the given content. + * A page can turn it on or off with `tabBarScrollEffect`. Otherwise, it + * applies to every page of a tab bar that is in a page, and for a tab bar + * in `ion-tabs` only to pages that cannot go back, since the tab bar + * is expected to stay in place on pages that were navigated to. + */ + private isScrollEffectEnabled(contentEl: HTMLElement, isInTabs: boolean) { + const { tabBarScrollEffect } = contentEl as HTMLIonContentElement; + + if (tabBarScrollEffect !== undefined) { + return tabBarScrollEffect; + } + + return !isInTabs || contentEl.closest('.ion-page')?.classList.contains('can-go-back') !== true; + } + + private setupScrollEffectForContent = async (contentEl: HTMLElement | null, isInTabs: boolean) => { + const setupId = ++this.contentSetupId; + + if (contentEl !== null) { + // The properties of the content can only be read once it is ready + await new Promise((resolve) => componentOnReady(contentEl, resolve)); + + if (this.contentSetupId !== setupId) { + return; } + } + + const isEnabled = contentEl !== null && this.isScrollEffectEnabled(contentEl, isInTabs); + + if (contentEl === this.activeContentEl && isEnabled === this.isActiveContentEnabled) { return; } - const appRootSelector = config.get('appRootSelector', 'ion-app'); - const pageEl = this.el.closest(`${appRootSelector}, ion-page, .ion-page, page-inner`); - const contentEl = pageEl ? findIonContent(pageEl) : null; + this.activeContentEl = contentEl; + this.isActiveContentEnabled = isEnabled; + this.destroyScrollEffectForContent(); - if (!contentEl) { + if (contentEl === null || !isEnabled) { + this.el.style.removeProperty('--opacity-scale'); return; } + if (this.scrollEffect === 'fade') { + await this.setupFadeTabBar(contentEl); + } else { + await this.setupHideTabBar(contentEl); + } + }; + + private setupHideTabBar = async (contentEl: HTMLElement) => { + const isTop = this.el.getAttribute('slot') === 'top'; + const setupPromise = createScrollHideController(contentEl, { el: this.el, + edge: isTop ? 'top' : 'bottom', cssVar: '--internal-tab-bar-hide-height', hiddenClass: 'tab-bar-scroll-hidden', + settlingClass: 'tab-bar-scroll-settling', contentPartnerClass: 'content-tab-bar-hide-scroll-partner', contentHiddenClass: 'content-tab-bar-hide-scroll-hidden', - shouldKeepAriaHidden: () => this.keyboardVisible && this.el.getAttribute('slot') !== 'top', + getHideDistance: () => { + const { position, top, bottom } = getComputedStyle(this.el); + + // A tab bar that floats above the content also has to move past the space around it + const spacing = position === 'absolute' ? parseFloat(isTop ? top : bottom) || 0 : 0; + + return this.el.offsetHeight + spacing; + }, }); this.scrollHideCtrlPromise = setupPromise; @@ -238,13 +330,56 @@ export class TabBar implements ComponentInterface { } }; - private destroyScrollEffect() { + private setupFadeTabBar = async (contentEl: HTMLElement) => { + const setupId = ++this.fadeSetupId; + + const scrollEl = await getScrollElement(contentEl); + + if (this.fadeSetupId !== setupId) { + return; + } + + this.fadeScrollEl = scrollEl; + + /** + * Handle fading of the tab bar background on scroll + */ + this.fadeScrollCallback = () => { + handleFooterFade(scrollEl, this.el); + }; + scrollEl.addEventListener('scroll', this.fadeScrollCallback); + + handleFooterFade(scrollEl, this.el); + }; + + private destroyScrollEffectForContent() { + this.fadeSetupId++; this.scrollHideCtrlPromise = null; if (this.scrollHideCtrl) { this.scrollHideCtrl.destroy(); this.scrollHideCtrl = undefined; } + + if (this.fadeScrollEl && this.fadeScrollCallback) { + this.fadeScrollEl.removeEventListener('scroll', this.fadeScrollCallback); + } + this.fadeScrollEl = undefined; + this.fadeScrollCallback = undefined; + } + + private destroyScrollEffect() { + this.contentSetupId++; + this.activeContentEl = undefined; + this.isActiveContentEnabled = false; + + if (this.stopObservingContent) { + this.stopObservingContent(); + this.stopObservingContent = undefined; + } + + this.destroyScrollEffectForContent(); + this.el.style.removeProperty('--opacity-scale'); } private getShape(): string | undefined { @@ -277,10 +412,12 @@ export class TabBar implements ComponentInterface { 'tab-bar-translucent': translucent, 'tab-bar-hidden': shouldHide, 'tab-bar-scroll-effect-hide': scrollEffect === 'hide', + 'tab-bar-scroll-effect-fade': scrollEffect === 'fade', [`tab-bar-${expand}`]: true, [`tab-bar-${shape}`]: shape !== undefined, })} > + {scrollEffect === 'fade' &&
} ); diff --git a/core/src/components/tab-bar/test/scroll-effect-fade/index.html b/core/src/components/tab-bar/test/scroll-effect-fade/index.html new file mode 100644 index 00000000000..0deba71d40f --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-fade/index.html @@ -0,0 +1,44 @@ + + + + + Tab Bar - Scroll Effect Fade + + + + + + + + + + +
+ + + Header + + + +
+

Content

+

Scroll down. The tab bar background should fade out when the content is scrolled to the end.

+
+
+ + + + Tab One + + + + Tab Two + + +
+
+ + diff --git a/core/src/components/tab-bar/test/scroll-effect-fade/tab-bar.e2e.ts b/core/src/components/tab-bar/test/scroll-effect-fade/tab-bar.e2e.ts new file mode 100644 index 00000000000..b4ef31b46b9 --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-fade/tab-bar.e2e.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tab-bar: scroll-effect-fade'), () => { + test('should have the scroll-effect-fade class when scrollEffect is set to fade', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-fade', config); + + const tabBar = page.locator('ion-tab-bar'); + await expect(tabBar).toHaveClass(/tab-bar-scroll-effect-fade/); + }); + + test('should fade out the background when the content is scrolled to the end', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-fade', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('ion-content'); + + // Initially, the background is visible + await expect.poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--opacity-scale'))).toBe('1'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect.poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--opacity-scale'))).toBe('0'); + }); + + test('should fade the background back in when scrolling away from the end', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-fade', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('ion-content'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await expect.poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--opacity-scale'))).toBe('0'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + + await expect.poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--opacity-scale'))).toBe('1'); + }); + + test('should not fade the tab buttons', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-fade', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('ion-content'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await expect.poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--opacity-scale'))).toBe('0'); + + const background = tabBar.locator('.tab-bar-background'); + await expect(background).toHaveCSS('opacity', '0'); + await expect(tabBar).toHaveCSS('opacity', '1'); + + // The tab buttons do not paint a background of their own, since it would cover the faded one + await expect(tabBar.locator('ion-tab-button').first()).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + }); + }); +}); diff --git a/core/src/components/tab-bar/test/scroll-effect-hide-tabs/index.html b/core/src/components/tab-bar/test/scroll-effect-hide-tabs/index.html new file mode 100644 index 00000000000..215d0af9539 --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide-tabs/index.html @@ -0,0 +1,117 @@ + + + + + Tab Bar - Scroll Effect Hide Tabs + + + + + + + + + + + + + + + + + + + Tab Two + + + +
Tab Two
+
+
+ + + + + Tab Three + + + +
Tab Three
+
+
+ + + + Tab One + + + Tab Two + + + Tab Three + + +
+
+ + + + diff --git a/core/src/components/tab-bar/test/scroll-effect-hide-tabs/tab-bar.e2e.ts b/core/src/components/tab-bar/test/scroll-effect-hide-tabs/tab-bar.e2e.ts new file mode 100644 index 00000000000..0bc1d49b58f --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide-tabs/tab-bar.e2e.ts @@ -0,0 +1,195 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * The tab bar starts following a page once the page is visible, so wait + * for the content to be set up before scrolling it. + */ +const scrollToBottom = async (content: Locator) => { + await expect(content).toHaveClass(/content-tab-bar-hide-scroll-partner/); + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); +}; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tab-bar: scroll-effect-hide-tabs'), () => { + test('should hide the tab bar when scrolling down the first tab', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollToBottom(page.locator('ion-tab[tab="tab-one"] ion-content')); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should hide the tab bar when scrolling down after switching tabs', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await page.click('ion-tab-button[tab="tab-two"]'); + await expect(page.locator('ion-tab[tab="tab-two"]')).toBeVisible(); + + await scrollToBottom(page.locator('ion-tab[tab="tab-two"] ion-content')); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should not hide the tab bar on a page that can go back', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + const pageOneContent = page.locator('page-one ion-content'); + + await expect(pageOneContent).toHaveClass(/content-tab-bar-hide-scroll-partner/); + + await page.click('#push-page-two'); + await expect(page.locator('page-two ion-content')).toBeVisible(); + + // The tab bar stopped following the first page and does not follow the pushed page + await expect(pageOneContent).not.toHaveClass(/content-tab-bar-hide-scroll-partner/); + await expect(page.locator('page-two ion-content')).not.toHaveClass(/content-tab-bar-hide-scroll-partner/); + + await page.locator('page-two ion-content').evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should show the tab bar and hide it again when going back to the root page', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollToBottom(page.locator('page-one ion-content')); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + await page.locator('ion-nav').evaluate((el: HTMLIonNavElement) => el.push('page-two')); + await expect(page.locator('page-two ion-content')).toBeVisible(); + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + + await page.locator('ion-nav').evaluate((el: HTMLIonNavElement) => el.pop()); + await expect(page.locator('page-two')).toHaveCount(0); + + // The first page keeps its scroll position, so start from the top again + const content = page.locator('page-one ion-content'); + await expect(content).toHaveClass(/content-tab-bar-hide-scroll-partner/); + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + // Scrolls in the same frame are handled as one, so wait for the scroll to be handled + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await scrollToBottom(content); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should hide the tab bar on a page that can go back when tabBarScrollEffect is true', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await page.click('#push-page-three'); + await expect(page.locator('page-three ion-content')).toBeVisible(); + + await scrollToBottom(page.locator('page-three ion-content')); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should not hide the tab bar on a root page when tabBarScrollEffect is false', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await page.click('ion-tab-button[tab="tab-three"]'); + await expect(page.locator('ion-tab[tab="tab-three"]')).toBeVisible(); + + // The tab bar stopped following the first tab and does not follow this one + await expect(page.locator('ion-tab[tab="tab-one"] ion-content')).not.toHaveClass( + /content-tab-bar-hide-scroll-partner/ + ); + + await page + .locator('ion-tab[tab="tab-three"] ion-content') + .evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should update the tab bar when tabBarScrollEffect changes', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('page-one ion-content'); + + await scrollToBottom(content); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + await content.evaluate((el: HTMLIonContentElement) => (el.tabBarScrollEffect = false)); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + await expect(content).not.toHaveClass(/content-tab-bar-hide-scroll-partner/); + + await content.evaluate((el: HTMLIonContentElement) => (el.tabBarScrollEffect = undefined)); + + await expect(content).toHaveClass(/content-tab-bar-hide-scroll-partner/); + }); + + test('should follow the content when the tabs are in a page that is not visible yet', async ({ page }) => { + // Frameworks render the page that contains the tabs as invisible until it transitions in + await page.setContent( + ` + +
+ + + +
Tab One
+
+
+ + + Tab One + + +
+
+
+ `, + config + ); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollToBottom(page.locator('ion-content')); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should show the tab bar when switching to another tab', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollToBottom(page.locator('ion-tab[tab="tab-one"] ion-content')); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + // The hidden tab bar cannot be interacted with, so switch tabs programmatically + await page.locator('ion-tabs').evaluate((el: HTMLIonTabsElement) => el.select('tab-two')); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should stop hiding the tab bar when the scroll effect is removed', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-tabs', config); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollToBottom(page.locator('ion-tab[tab="tab-one"] ion-content')); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + await tabBar.evaluate((el: HTMLIonTabBarElement) => (el.scrollEffect = undefined)); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + await expect(tabBar).not.toHaveAttribute('inert'); + }); + }); +}); diff --git a/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts b/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts index 48d7ec45676..3cfbe2e361f 100644 --- a/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts +++ b/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts @@ -1,6 +1,41 @@ import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; +interface ScrollStep { + /** Pixels to scroll. Positive scrolls down, negative scrolls back up. */ + distance: number; + /** How many times the distance is scrolled. */ + count: number; + /** Milliseconds between each scroll. */ + delay: number; +} + +/** + * Runs the scroll steps in a row inside of the page, so that the time between + * the scrolls does not depend on the test runner. Returns how far the tab bar + * is out of view after each step. + */ +const scrollAndMeasure = (page: Page, steps: ScrollStep[]) => { + return page.evaluate(async (steps) => { + const content = document.querySelector('ion-content') as HTMLIonContentElement; + const tabBar = document.querySelector('ion-tab-bar') as HTMLElement; + const scrollEl = await content.getScrollElement(); + const nextFrames = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + const offsets: number[] = []; + for (const { distance, count, delay } of steps) { + for (let i = 0; i < count; i++) { + scrollEl.scrollTop += distance; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + await nextFrames(); + offsets.push(parseFloat(tabBar.style.getPropertyValue('--internal-scroll-hide-offset') || '0')); + } + return offsets; + }, steps); +}; + configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('tab-bar: scroll-effect-hide'), () => { test('should have the scroll-effect-hide class when scrollEffect is set to hide', async ({ page }) => { @@ -22,7 +57,9 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await page.locator('ion-tab-bar.tab-bar-scroll-hidden').waitFor(); await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); - await expect(tabBar).toHaveAttribute('inert', ''); + // A tab bar that is out of view stays reachable for keyboard and screen reader users + await expect(tabBar).not.toHaveAttribute('inert'); + await expect(tabBar).not.toHaveAttribute('aria-hidden'); // Verify the tab bar is visually off-screen via transform const transform = await tabBar.evaluate((el) => getComputedStyle(el).transform); @@ -44,5 +81,125 @@ configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }). await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); await expect(tabBar).not.toHaveAttribute('inert'); }); + test('should move out of view with the scroll, pixel by pixel', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const [afterFirst, afterSecond] = await scrollAndMeasure(page, [ + { distance: 5, count: 4, delay: 20 }, + { distance: 5, count: 4, delay: 20 }, + ]); + + expect(afterFirst).toBe(20); + expect(afterSecond).toBe(40); + }); + + test('should not return when scrolling back slowly', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + + // Scrolls 120px back at 0.1px per millisecond, however far, this is not a quick scroll back + const [, , offsetAfterSlowScroll] = await scrollAndMeasure(page, [ + { distance: 400, count: 1, delay: 100 }, + { distance: 0, count: 1, delay: 300 }, + { distance: -2, count: 60, delay: 20 }, + ]); + + expect(offsetAfterSlowScroll).toBeGreaterThan(0); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should return when scrolling back quickly', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + + await scrollAndMeasure(page, [{ distance: 400, count: 1, delay: 300 }]); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + const [offsetAfterQuickScroll] = await scrollAndMeasure(page, [{ distance: -100, count: 1, delay: 20 }]); + + expect(offsetAfterQuickScroll).toBe(0); + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should follow a slow scroll back while it is partly out of view', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + await scrollAndMeasure(page, [{ distance: 400, count: 1, delay: 300 }]); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + const hideHeight = await tabBar.evaluate((el) => + parseFloat(el.style.getPropertyValue('--internal-tab-bar-hide-height')) + ); + + const [afterQuickScrollBack, afterScrollingForward, afterSlowScrollBack] = await scrollAndMeasure(page, [ + { distance: -30, count: 1, delay: 20 }, + { distance: 10, count: 1, delay: 20 }, + // 0.1px per millisecond is far below a quick scroll back + { distance: -2, count: 5, delay: 20 }, + ]); + + expect(afterQuickScrollBack).toBe(hideHeight - 30); + expect(afterScrollingForward).toBe(hideHeight - 20); + expect(afterSlowScrollBack).toBe(hideHeight - 30); + }); + + test('should settle to the closest edge after scrolling stops', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + + // Moves the tab bar out of view and brings back more than half of it, then stops scrolling + await scrollAndMeasure(page, [{ distance: 400, count: 1, delay: 300 }]); + await scrollAndMeasure(page, [{ distance: -40, count: 1, delay: 20 }]); + + await expect + .poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--internal-scroll-hide-offset'))) + .toBe('0px'); + }); + test('should show again when an element in it receives focus', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('ion-content'); + + // Tab buttons can only be focused when they have an href, like they do in the frameworks + await page + .locator('ion-tab-button') + .evaluateAll((buttons) => buttons.forEach((button) => button.setAttribute('href', '#'))); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-tab-bar.tab-bar-scroll-hidden').waitFor(); + + // Keyboard users cannot scroll back quickly, so focus has to bring it back + await page.locator('ion-tab-button a').first().focus(); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + await expect + .poll(() => tabBar.evaluate((el) => el.style.getPropertyValue('--internal-scroll-hide-offset'))) + .toBe('0px'); + }); + + test('should not show again when scrolling back slowly while an element in it is not focused', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + const content = page.locator('ion-content'); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-tab-bar.tab-bar-scroll-hidden').waitFor(); + + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + + for (let i = 0; i < 20; i++) { + scrollEl.scrollTop -= 2; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + }); }); }); diff --git a/core/src/utils/content/index.ts b/core/src/utils/content/index.ts index 3c4f0967592..a7a1301227f 100644 --- a/core/src/utils/content/index.ts +++ b/core/src/utils/content/index.ts @@ -58,6 +58,71 @@ export const findClosestIonContent = (el: Element) => { return el.closest(ION_CONTENT_SELECTOR); }; +/** + * Queries the content of the page that is currently visible inside the given element. + * + * Elements such as `ion-tabs` render the content of several pages at once, but only + * show one of them. Hidden pages, pages that have not started transitioning in, + * and content that belongs to an overlay or a menu are ignored. If more than one + * page is visible, such as during a transition, the last one in the DOM is used. + */ +export const findVisibleIonContent = (el: Element) => { + const contents = Array.from(el.querySelectorAll(ION_CONTENT_SELECTOR)); + const visibleContents = contents.filter((content) => { + /** + * Only the pages inside of the element matter. The element itself may be in + * a page that is not visible yet, such as the page that contains `ion-tabs` + * while it is transitioning in. + */ + const hiddenAncestor = content.closest('.ion-page-invisible, ion-modal, ion-popover, ion-menu'); + + return content.offsetParent !== null && (hiddenAncestor === null || !el.contains(hiddenAncestor)); + }); + + return visibleContents.length > 0 ? visibleContents[visibleContents.length - 1] : null; +}; + +/** + * Calls the callback with the content that is visible inside of the given element, + * and again whenever a page is shown, hidden, added, or removed, even if the visible + * content stays the same, since the page itself may have changed. Returns a function + * that stops observing. + */ +export const observeVisibleIonContent = (el: Element, callback: (contentEl: HTMLElement | null) => void) => { + const checkContent = () => callback(findVisibleIonContent(el)); + + checkContent(); + + if (typeof MutationObserver === 'undefined') { + return () => undefined; + } + + const affectsContent = (node: Node) => + node.nodeType === Node.ELEMENT_NODE && + ((node as Element).matches(`.ion-page, ion-tab, ${ION_CONTENT_SELECTOR}`) || + (node as Element).querySelector(ION_CONTENT_SELECTOR) !== null); + + /** + * Pages are shown and hidden by adding and removing elements + * and by toggling classes on the pages. Other changes in the + * subtree, such as pressed states or list items, are ignored. + */ + const observer = new MutationObserver((records) => { + const hasPageChange = records.some((record) => + record.type === 'childList' + ? Array.from(record.addedNodes).concat(Array.from(record.removedNodes)).some(affectsContent) + : (record.target as Element).matches('.ion-page, ion-tab') + ); + + if (hasPageChange) { + checkContent(); + } + }); + observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); +}; + /** * Queries the custom scroll host an `ion-refresher` scrolls with in the given * `ion-content`. A refresher only pairs with the first host. diff --git a/core/src/utils/scroll-hide-controller.ts b/core/src/utils/scroll-hide-controller.ts index f22e2752da8..754ec53e543 100644 --- a/core/src/utils/scroll-hide-controller.ts +++ b/core/src/utils/scroll-hide-controller.ts @@ -1,28 +1,60 @@ import { readTask, writeTask } from '@stencil/core'; import { getScrollElement } from './content'; +import { clamp } from './helpers'; -const TOP_VISIBLE_THRESHOLD = 80; -const SCROLL_HIDE_THRESHOLD = 60; -const WHEEL_SUPPRESS_DURATION_MS = 80; -const SUPPRESS_SHOW_DURATION_MS = 400; +/** + * Name of the CSS variable that holds how far the bar is currently + * moved out of view, in pixels. Components use it to translate themselves. + */ +const OFFSET_CSS_VAR = '--internal-scroll-hide-offset'; + +/** + * Name of the CSS variable that holds how far the bar is out of view as + * a number from 0 (fully visible) to 1 (fully hidden). Components that can + * come to rest in front of other content when they move out of view, such + * as a footer above a tab bar, use it to fade out. + */ +const PROGRESS_CSS_VAR = '--internal-scroll-hide-progress'; + +/** + * When the content is scrolled back towards a bar that is fully out of view, + * the bar only returns if the user scrolls back quickly. Scrolling back slowly, + * however far, keeps the bar out of the way for the user who is nudging the + * content or re-reading something. A bar that is only partly out of view + * follows the scroll back at any speed. + */ + +// Speed in pixels per millisecond that is considered a quick scroll back +const REVEAL_VELOCITY = 0.6; +// Time in milliseconds over which the scroll back speed is measured +const VELOCITY_WINDOW_MS = 100; +// Time in milliseconds without scrolling after which a partly hidden bar settles +const SETTLE_DELAY_MS = 120; +// Time in milliseconds that the bar takes to settle +const SETTLE_DURATION_MS = 200; export interface ScrollHideOptions { /** The component's host element. */ el: HTMLElement; + /** The side of the screen that the bar hides towards. */ + edge: 'top' | 'bottom'; /** CSS variable name for the hide height, e.g. `--internal-header-hide-height`. */ cssVar: string; /** Class toggled on the component when hidden, e.g. `header-scroll-hidden`. */ hiddenClass: string; + /** Class toggled on the component while it settles, e.g. `header-scroll-settling`. */ + settlingClass: string; /** Class added to content when the scroll-hide effect is active, e.g. `content-header-hide-scroll-partner`. */ contentPartnerClass: string; /** Class toggled on content when hidden, e.g. `content-header-hide-scroll-hidden`. */ contentHiddenClass: string; /** - * Optional guard called before removing aria-hidden on show. - * Return `true` to keep aria-hidden (e.g. when the keyboard is open). + * Optional distance in pixels that the component has to move to be + * out of view. Defaults to the height of the component. Components + * that float above the content need to move further than their height. */ - shouldKeepAriaHidden?: () => boolean; + getHideDistance?: () => number; } export type ScrollHideController = { @@ -40,25 +72,34 @@ export type ScrollHideController = { }; /** - * Creates a controller that manages scroll-based hide/show behavior - * for headers, footers, and tab bars. + * Creates a controller that moves a header, footer, or tab bar out of view + * as the content scrolls, and back into view when the user scrolls back. + * + * The component follows the scroll pixel by pixel. Scrolling towards the end + * of the content moves it out of view by the scrolled distance. Scrolling back + * moves a component that is partly out of view back into view by the same + * distance. A component that is fully out of view only returns when the user + * scrolls back quickly, so that slow scrolling does not bring it back. Once it + * is returning, it follows the scroll until it is fully visible or the user + * scrolls forward again. + * The component is always fully visible at the start of the content. * - * Listens to both wheel and scroll events. Wheel events (desktop mice) - * give direction instantly via deltaY. Scroll events cover touch, - * trackpad, and programmatic scrolling where wheel doesn't fire. - * A short suppression window stops them from double-processing the - * same gesture. + * When the user stops scrolling while the component is only partly visible, + * it settles to the closest edge. A component that returned because the user + * scrolled back quickly always settles fully into view. * - * When the user reverses scroll direction, we save that position as - * an anchor. The bar only hides after scrolling 60px past that - * anchor, which prevents flickering on small or jittery movements. - * Showing is immediate on direction change. + * Only scroll events are used, so touch, wheel, trackpad, keyboard, and + * programmatic scrolling all behave the same. * - * When `options` is provided, the controller also handles the DOM - * setup: ResizeObserver for height tracking, CSS variable read/write, - * content partner/hidden class toggling, inert/aria-hidden, and - * full teardown cleanup. This avoids duplicating the same setup - * across header, footer, and tab-bar. + * A component that is out of view stays in the tab order and in the + * accessibility tree, and moves back into view when an element in it + * receives focus. This way keyboard and screen reader users can reach it + * without having to scroll back quickly. + * + * The controller also handles the DOM setup: a ResizeObserver for height + * tracking, CSS variable read/write, content partner/hidden class toggling, + * and full teardown cleanup. This avoids duplicating the same setup across + * header, footer, and tab-bar. * * @internal * @param contentEl The content element to resolve the scroll element from. @@ -68,134 +109,215 @@ export const createScrollHideController = async ( contentEl: HTMLElement, options: ScrollHideOptions ): Promise => { - const { el, cssVar, hiddenClass, contentPartnerClass, contentHiddenClass, shouldKeepAriaHidden } = options; + const { el, edge, cssVar, hiddenClass, settlingClass, contentPartnerClass, contentHiddenClass, getHideDistance } = + options; let controllerIsHidden = false; let resizeObserver: ResizeObserver | undefined; // --- DOM setup: height tracking, classes, a11y --- + /** + * How far the component moves to be out of view, and how far + * it is moved out of view at the moment. + */ + let hideDistance = 0; + let offset = 0; + + const measureHideHeight = () => { + const heightPx = el.offsetHeight; + hideDistance = getHideDistance !== undefined ? getHideDistance() : heightPx; + + /** + * The content only needs to make room for components that take up + * space in the layout. Components that float above the content, + * such as a compact tab bar, do not. + */ + const position = getComputedStyle(el).position; + const contentHeightPx = position === 'absolute' || position === 'fixed' ? 0 : heightPx; + + return { heightPx, contentHeightPx }; + }; + + const applyHideHeight = ({ heightPx, contentHeightPx }: { heightPx: number; contentHeightPx: number }) => { + el.style.setProperty(cssVar, `${heightPx}px`); + contentEl.style.setProperty(cssVar, `${contentHeightPx}px`); + }; + const updateHideHeight = () => { readTask(() => { - const heightPx = el.offsetHeight; + const heights = measureHideHeight(); + writeTask(() => { - el.style.setProperty(cssVar, `${heightPx}px`); - contentEl.style.setProperty(cssVar, `${heightPx}px`); + applyHideHeight(heights); + + // A component that is out of view stays out of view when its size changes + if (controllerIsHidden || offset > hideDistance) { + setOffset(hideDistance); + } }); }); }; const setHidden = (hidden: boolean) => { + if (hidden === controllerIsHidden) { + return; + } + controllerIsHidden = hidden; el.classList.toggle(hiddenClass, hidden); + contentEl.classList.toggle(contentHiddenClass, hidden); + }; - if (hidden) { - el.setAttribute('inert', ''); - el.setAttribute('aria-hidden', 'true'); - } else { - el.removeAttribute('inert'); - if (!shouldKeepAriaHidden || !shouldKeepAriaHidden()) { - el.removeAttribute('aria-hidden'); - } - } + const setOffset = (value: number) => { + offset = value; - contentEl.classList.toggle(contentHiddenClass, hidden); + el.style.setProperty(OFFSET_CSS_VAR, `${offset}px`); + el.style.setProperty(PROGRESS_CSS_VAR, `${hideDistance > 0 ? offset / hideDistance : 0}`); + setHidden(hideDistance > 0 && offset >= hideDistance); }; // --- Scroll math --- const scrollEl = await getScrollElement(contentEl); - let lastScrollPosition = 0; - let scrollPositionAtDirectionChange = 0; - let lastWheelEventTimestamp = 0; - let suppressShowUntil = 0; + let lastScrollTop = 0; + let isReturning = false; + let isQuickScrollBack = false; + let scrollBackSamples: { time: number; distance: number }[] = []; + let settleTimeout: ReturnType | undefined; + let settlingTimeout: ReturnType | undefined; - const commitHide = (hidden: boolean) => { - if (hidden) { - suppressShowUntil = Date.now() + SUPPRESS_SHOW_DURATION_MS; - } else { - suppressShowUntil = 0; + /** + * Speed in pixels per millisecond that the content was scrolled back with + * over the last moments. A single large jump counts as happening in one frame. + */ + const getScrollBackVelocity = (now: number) => { + scrollBackSamples = scrollBackSamples.filter((sample) => now - sample.time <= VELOCITY_WINDOW_MS); + + if (scrollBackSamples.length === 0) { + return 0; } - setHidden(hidden); + + const distance = scrollBackSamples.reduce((total, sample) => total + sample.distance, 0); + const elapsed = now - scrollBackSamples[0].time; + + return distance / Math.max(elapsed + 16, 16); }; - const handleWheel = (wheelEvent: WheelEvent) => { - lastWheelEventTimestamp = Date.now(); + const stopSettling = () => { + if (settlingTimeout !== undefined) { + clearTimeout(settlingTimeout); + settlingTimeout = undefined; + } + el.classList.remove(settlingClass); + }; - readTask(() => { - const currentScrollTop = scrollEl.scrollTop; + /** + * A bar that is in the middle of settling is somewhere between the + * offset that was set and the offset it started from, so when the user + * scrolls again, continue from where the bar currently is. + */ + const syncOffsetWithPosition = () => { + if (!el.classList.contains(settlingClass)) { + return; + } - // Both wheel and scroll events fire for mouse wheel input. - // Scroll events are suppressed for 80ms after a wheel event, - // but when they resume, handleScroll needs an up-to-date - // lastScrollPosition or it may detect the wrong direction. - lastScrollPosition = currentScrollTop; + const translateY = new DOMMatrix(getComputedStyle(el).transform).m42; + offset = clamp(0, edge === 'top' ? -translateY : translateY, hideDistance); + stopSettling(); + }; - if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { - if (controllerIsHidden) { - writeTask(() => commitHide(false)); - } - return; - } + const settle = () => { + settleTimeout = undefined; - if (wheelEvent.deltaY < 0) { - scrollPositionAtDirectionChange = currentScrollTop; - if (controllerIsHidden) { - writeTask(() => commitHide(false)); - } - } else if (wheelEvent.deltaY > 0) { - const scrolledSinceDirectionChange = currentScrollTop - scrollPositionAtDirectionChange; - if (scrolledSinceDirectionChange >= SCROLL_HIDE_THRESHOLD && !controllerIsHidden) { - writeTask(() => commitHide(true)); - } - } - }); + if (offset <= 0 || offset >= hideDistance) { + return; + } + + /** + * Close to the start of the content there is nothing to make + * room for, so the component stays visible. A component that returned + * on a quick scroll back continues to return, and otherwise it moves + * to the closest edge. + */ + const isNearStart = scrollEl.scrollTop < hideDistance; + const target = isQuickScrollBack || isNearStart || offset < hideDistance / 2 ? 0 : hideDistance; + + el.classList.add(settlingClass); + settlingTimeout = setTimeout(stopSettling, SETTLE_DURATION_MS); + setOffset(target); }; const handleScroll = () => { - if (Date.now() - lastWheelEventTimestamp < WHEEL_SUPPRESS_DURATION_MS) { + syncOffsetWithPosition(); + + const maxScroll = Math.max(scrollEl.scrollHeight - scrollEl.clientHeight, 0); + + // Rubber band scrolling past either end is not a scroll direction. + const scrollTop = clamp(0, scrollEl.scrollTop, maxScroll); + const delta = scrollTop - lastScrollTop; + lastScrollTop = scrollTop; + + if (delta === 0) { return; } - readTask(() => { - const currentScrollTop = scrollEl.scrollTop; + const now = performance.now(); + let nextOffset = offset; - if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { - if (controllerIsHidden) { - writeTask(() => commitHide(false)); - } - lastScrollPosition = currentScrollTop; - return; - } + if (delta > 0) { + isReturning = false; + isQuickScrollBack = false; + scrollBackSamples = []; + nextOffset = offset + delta; + } else { + scrollBackSamples.push({ time: now, distance: -delta }); - if (currentScrollTop === lastScrollPosition) { - return; + if (getScrollBackVelocity(now) >= REVEAL_VELOCITY) { + isQuickScrollBack = true; } - const isScrollingDown = currentScrollTop > lastScrollPosition; - const wasScrollingDown = lastScrollPosition > scrollPositionAtDirectionChange; + if (isQuickScrollBack || offset < hideDistance) { + isReturning = true; + } - if (isScrollingDown !== wasScrollingDown) { - scrollPositionAtDirectionChange = lastScrollPosition; + if (isReturning) { + nextOffset = offset + delta; } + } - const scrolledSinceDirectionChange = Math.abs(currentScrollTop - scrollPositionAtDirectionChange); - const requiredScrollDistance = isScrollingDown ? SCROLL_HIDE_THRESHOLD : 0; - lastScrollPosition = currentScrollTop; + /** + * The component can only move out of view as far as the content has + * been scrolled, so it is fully visible at the start of the content. + */ + nextOffset = clamp(0, nextOffset, Math.min(hideDistance, scrollTop)); - if (scrolledSinceDirectionChange < requiredScrollDistance) { - return; - } + if (nextOffset !== offset) { + setOffset(nextOffset); + } - const shouldHide = isScrollingDown; - if (shouldHide !== controllerIsHidden) { - if (!shouldHide && Date.now() < suppressShowUntil) { - return; - } - writeTask(() => commitHide(shouldHide)); - } - }); + if (settleTimeout !== undefined) { + clearTimeout(settleTimeout); + } + settleTimeout = setTimeout(settle, SETTLE_DELAY_MS); + }; + + /** + * Brings the component back into view when an element in it receives + * focus, since the focused element would otherwise be out of view. + */ + const handleFocusIn = () => { + if (offset <= 0) { + return; + } + + isQuickScrollBack = true; + isReturning = true; + + el.classList.add(settlingClass); + settlingTimeout = setTimeout(stopSettling, SETTLE_DURATION_MS); + setOffset(0); }; // --- Controller --- @@ -205,7 +327,10 @@ export const createScrollHideController = async ( const init = () => { initialized = true; - updateHideHeight(); + // Measured right away, so that scrolling can follow from the first frame + applyHideHeight(measureHideHeight()); + + lastScrollTop = scrollEl.scrollTop; if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(() => updateHideHeight()); @@ -213,7 +338,7 @@ export const createScrollHideController = async ( } scrollEl.addEventListener('scroll', handleScroll, { passive: true }); - scrollEl.addEventListener('wheel', handleWheel as EventListener, { passive: true }); + el.addEventListener('focusin', handleFocusIn); contentEl.classList.add(contentPartnerClass); }; @@ -222,7 +347,13 @@ export const createScrollHideController = async ( if (!initialized) return; scrollEl.removeEventListener('scroll', handleScroll); - scrollEl.removeEventListener('wheel', handleWheel as EventListener); + el.removeEventListener('focusin', handleFocusIn); + + if (settleTimeout !== undefined) { + clearTimeout(settleTimeout); + settleTimeout = undefined; + } + stopSettling(); if (resizeObserver) { resizeObserver.disconnect(); @@ -234,11 +365,11 @@ export const createScrollHideController = async ( if (controllerIsHidden) { el.classList.remove(hiddenClass); - el.removeAttribute('inert'); - el.removeAttribute('aria-hidden'); controllerIsHidden = false; } el.style.removeProperty(cssVar); + el.style.removeProperty(OFFSET_CSS_VAR); + el.style.removeProperty(PROGRESS_CSS_VAR); }; const isHidden = () => controllerIsHidden; diff --git a/packages/angular/src/lazy/directives/proxies.ts b/packages/angular/src/lazy/directives/proxies.ts index 885b667cfef..245fff8794f 100644 --- a/packages/angular/src/lazy/directives/proxies.ts +++ b/packages/angular/src/lazy/directives/proxies.ts @@ -652,7 +652,7 @@ export declare interface IonCol extends Components.IonCol {} @ProxyCmp({ - inputs: ['color', 'fixedSlotPlacement', 'forceOverscroll', 'fullscreen', 'mode', 'scrollEvents', 'scrollX', 'scrollY', 'theme'], + inputs: ['color', 'fixedSlotPlacement', 'forceOverscroll', 'fullscreen', 'mode', 'scrollEvents', 'scrollX', 'scrollY', 'tabBarScrollEffect', 'theme'], methods: ['getScrollElement', 'scrollToTop', 'scrollToBottom', 'scrollByPoint', 'scrollToPoint'] }) @Component({ @@ -660,7 +660,7 @@ export declare interface IonCol extends Components.IonCol {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'fixedSlotPlacement', { name: 'forceOverscroll', transform: nullableBooleanAttribute }, { name: 'fullscreen', transform: nullableBooleanAttribute }, 'mode', { name: 'scrollEvents', transform: nullableBooleanAttribute }, { name: 'scrollX', transform: nullableBooleanAttribute }, { name: 'scrollY', transform: nullableBooleanAttribute }, 'theme'], + inputs: ['color', 'fixedSlotPlacement', { name: 'forceOverscroll', transform: nullableBooleanAttribute }, { name: 'fullscreen', transform: nullableBooleanAttribute }, 'mode', { name: 'scrollEvents', transform: nullableBooleanAttribute }, { name: 'scrollX', transform: nullableBooleanAttribute }, { name: 'scrollY', transform: nullableBooleanAttribute }, { name: 'tabBarScrollEffect', transform: nullableBooleanAttribute }, 'theme'], outputs: ['ionScrollStart', 'ionScroll', 'ionScrollEnd'], standalone: false }) diff --git a/packages/angular/src/standalone/directives/ion-content.ts b/packages/angular/src/standalone/directives/ion-content.ts index a583ecd431e..e0091528faa 100644 --- a/packages/angular/src/standalone/directives/ion-content.ts +++ b/packages/angular/src/standalone/directives/ion-content.ts @@ -11,7 +11,7 @@ import { defineCustomElement as defineIonContent } from '@ionic/core/components/ @ProxyCmp({ defineCustomElementFn: defineIonContent, - inputs: ['color', 'fixedSlotPlacement', 'forceOverscroll', 'fullscreen', 'mode', 'scrollEvents', 'scrollX', 'scrollY', 'theme'], + inputs: ['color', 'fixedSlotPlacement', 'forceOverscroll', 'fullscreen', 'mode', 'scrollEvents', 'scrollX', 'scrollY', 'tabBarScrollEffect', 'theme'], methods: ['getScrollElement', 'scrollToTop', 'scrollToBottom', 'scrollByPoint', 'scrollToPoint'] }) @Component({ @@ -19,7 +19,7 @@ import { defineCustomElement as defineIonContent } from '@ionic/core/components/ changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'fixedSlotPlacement', { name: 'forceOverscroll', transform: nullableBooleanAttribute }, { name: 'fullscreen', transform: nullableBooleanAttribute }, 'mode', { name: 'scrollEvents', transform: nullableBooleanAttribute }, { name: 'scrollX', transform: nullableBooleanAttribute }, { name: 'scrollY', transform: nullableBooleanAttribute }, 'theme'], + inputs: ['color', 'fixedSlotPlacement', { name: 'forceOverscroll', transform: nullableBooleanAttribute }, { name: 'fullscreen', transform: nullableBooleanAttribute }, 'mode', { name: 'scrollEvents', transform: nullableBooleanAttribute }, { name: 'scrollX', transform: nullableBooleanAttribute }, { name: 'scrollY', transform: nullableBooleanAttribute }, { name: 'tabBarScrollEffect', transform: nullableBooleanAttribute }, 'theme'], outputs: ['ionScrollStart', 'ionScroll', 'ionScrollEnd'], }) export class IonContent { diff --git a/packages/angular/test/base/e2e/src/standalone/tab-bar-scroll-effect.spec.ts b/packages/angular/test/base/e2e/src/standalone/tab-bar-scroll-effect.spec.ts new file mode 100644 index 00000000000..c8597be7fa7 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/tab-bar-scroll-effect.spec.ts @@ -0,0 +1,119 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { ionPageVisible } from '../../utils/test-utils'; + +const getOpacityScale = (page: Page) => + page.locator('ion-tab-bar').evaluate((el) => el.style.getPropertyValue('--opacity-scale')); + +/** + * The tab bar starts following a page once the page is visible, so wait for + * the effect to be set up on the content before scrolling it. + */ +const scrollToBottom = async (page: Page, pageSelector: string) => { + const content = page.locator(`${pageSelector} ion-content`); + await expect(content).toHaveClass(/content-tab-bar-hide-scroll-partner/); + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); +}; + +const scrollToTop = (page: Page, pageSelector: string) => + page.locator(`${pageSelector} ion-content`).evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + +test.describe('Tab Bar: scrollEffect="hide"', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/tab-bar-scroll-effect/tab1'); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-tab1'); + }); + + test('should hide the tab bar when scrolling down the first tab', async ({ page }) => { + await scrollToBottom(page, 'app-tab-bar-scroll-effect-tab1'); + + await expect(page.locator('ion-tab-bar')).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should hide and show the tab bar when scrolling after switching tabs', async ({ page }) => { + await page.locator('ion-tab-button[tab="tab2"]').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-tab2'); + + await scrollToBottom(page, 'app-tab-bar-scroll-effect-tab2'); + await expect(page.locator('ion-tab-bar')).toHaveClass(/tab-bar-scroll-hidden/); + + await scrollToTop(page, 'app-tab-bar-scroll-effect-tab2'); + await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should not hide the tab bar when scrolling down a child page of a tab', async ({ page }) => { + await page.locator('#go-child').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-child'); + + // The tab bar stopped following the first tab and does not follow the child page + await expect(page.locator('app-tab-bar-scroll-effect-tab1 ion-content')).not.toHaveClass( + /content-tab-bar-hide-scroll-partner/ + ); + await page + .locator('app-tab-bar-scroll-effect-child ion-content') + .evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should show the tab bar when navigating to a child page and hide it again when going back', async ({ + page, + }) => { + await scrollToBottom(page, 'app-tab-bar-scroll-effect-tab1'); + await expect(page.locator('ion-tab-bar')).toHaveClass(/tab-bar-scroll-hidden/); + + await scrollToTop(page, 'app-tab-bar-scroll-effect-tab1'); + await page.locator('#go-child').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-child'); + await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-scroll-hidden/); + + await page.locator('app-tab-bar-scroll-effect-child ion-back-button').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-tab1'); + + await scrollToBottom(page, 'app-tab-bar-scroll-effect-tab1'); + await expect(page.locator('ion-tab-bar')).toHaveClass(/tab-bar-scroll-hidden/); + }); + + test('should hide the tab bar on a child page when tabBarScrollEffect is true', async ({ page }) => { + await page.locator('#go-child-effect').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-child'); + + await scrollToBottom(page, 'app-tab-bar-scroll-effect-child'); + + await expect(page.locator('ion-tab-bar')).toHaveClass(/tab-bar-scroll-hidden/); + }); +}); + +test.describe('Tab Bar: scrollEffect="fade"', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/tab-bar-scroll-effect/tab1?effect=fade'); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-tab1'); + }); + + test('should fade the tab bar background out at the end of the content', async ({ page }) => { + await expect.poll(() => getOpacityScale(page)).toBe('1'); + + await page + .locator('app-tab-bar-scroll-effect-tab1 ion-content') + .evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect.poll(() => getOpacityScale(page)).toBe('0'); + }); + + test('should follow the page that is visible after switching tabs', async ({ page }) => { + await page + .locator('app-tab-bar-scroll-effect-tab1 ion-content') + .evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await expect.poll(() => getOpacityScale(page)).toBe('0'); + + await page.locator('ion-tab-button[tab="tab2"]').click(); + await ionPageVisible(page, 'app-tab-bar-scroll-effect-tab2'); + await expect.poll(() => getOpacityScale(page)).toBe('1'); + + await page + .locator('app-tab-bar-scroll-effect-tab2 ion-content') + .evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + + await expect.poll(() => getOpacityScale(page)).toBe('0'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 337b57b8149..60da157d123 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -76,6 +76,17 @@ export const routes: Routes = [ { path: 'tab4', loadComponent: () => import('../tabs-search-params/tab4.component').then(c => c.TabsSearchParamsTab4Component) } ] }, + { path: 'tab-bar-scroll-effect', redirectTo: '/standalone/tab-bar-scroll-effect/tab1', pathMatch: 'full' }, + { + path: 'tab-bar-scroll-effect', + loadComponent: () => import('../tab-bar-scroll-effect/tab-bar-scroll-effect.component').then(c => c.TabBarScrollEffectComponent), + children: [ + { path: 'tab1', loadComponent: () => import('../tab-bar-scroll-effect/tab-bar-scroll-effect-tab1.component').then(c => c.TabBarScrollEffectTab1Component) }, + { path: 'tab1/child', loadComponent: () => import('../tab-bar-scroll-effect/tab-bar-scroll-effect-child.component').then(c => c.TabBarScrollEffectChildComponent) }, + { path: 'tab1/child-effect', data: { tabBarScrollEffect: true }, loadComponent: () => import('../tab-bar-scroll-effect/tab-bar-scroll-effect-child.component').then(c => c.TabBarScrollEffectChildComponent) }, + { path: 'tab2', loadComponent: () => import('../tab-bar-scroll-effect/tab-bar-scroll-effect-tab2.component').then(c => c.TabBarScrollEffectTab2Component) } + ] + }, { path: 'swipe-gesture-disabled', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled.component').then(c => c.SwipeGestureDisabledComponent), diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 0adb4664e58..cb5dcf4463f 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -104,6 +104,11 @@ Tabs Search Params Test + + + Tab Bar Scroll Effect Test + + diff --git a/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-child.component.ts b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-child.component.ts new file mode 100644 index 00000000000..c384aa5abba --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-child.component.ts @@ -0,0 +1,25 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonBackButton, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-bar-scroll-effect-child', + template: ` + + + + + + Child + + + +
+
+ `, + standalone: true, + imports: [IonBackButton, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class TabBarScrollEffectChildComponent { + tabBarScrollEffect: boolean | undefined = inject(ActivatedRoute).snapshot.data['tabBarScrollEffect']; +} diff --git a/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab1.component.ts b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab1.component.ts new file mode 100644 index 00000000000..1e58bf94a0e --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab1.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { IonButton, IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-bar-scroll-effect-tab1', + template: ` + + + Tab 1 + + + + Go to Child + + Go to Child with the tab bar effect + +
+
+ `, + standalone: true, + imports: [RouterLink, IonButton, IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class TabBarScrollEffectTab1Component {} diff --git a/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab2.component.ts b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab2.component.ts new file mode 100644 index 00000000000..d3c1d89c29d --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect-tab2.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-bar-scroll-effect-tab2', + template: ` + + + Tab 2 + + + +
+
+ `, + standalone: true, + imports: [IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class TabBarScrollEffectTab2Component {} diff --git a/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect.component.ts b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect.component.ts new file mode 100644 index 00000000000..1e8bf50f533 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-bar-scroll-effect/tab-bar-scroll-effect.component.ts @@ -0,0 +1,32 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs } from '@ionic/angular'; +import { addIcons } from 'ionicons'; +import { square, triangle } from 'ionicons/icons'; + +addIcons({ square, triangle }); + +@Component({ + selector: 'app-tab-bar-scroll-effect', + template: ` + + + + + Tab 1 + + + + Tab 2 + + + + `, + standalone: true, + imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs], +}) +export class TabBarScrollEffectComponent { + scrollEffect: 'hide' | 'fade' = this.route.snapshot.queryParamMap.get('effect') === 'fade' ? 'fade' : 'hide'; + + constructor(private route: ActivatedRoute) {} +} diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx index ab265036f7a..0cefe75f889 100644 --- a/packages/react/test/base/src/App.tsx +++ b/packages/react/test/base/src/App.tsx @@ -29,6 +29,7 @@ import TabsBasic from './pages/TabsBasic'; import NavComponent from './pages/navigation/NavComponent'; import NavModalComponent from './pages/navigation/NavModalComponent'; import TabsDirectNavigation from './pages/TabsDirectNavigation'; +import TabsScrollEffect from './pages/TabsScrollEffect'; import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes'; import IonModalConditional from './pages/overlay-components/IonModalConditional'; import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling'; @@ -71,6 +72,7 @@ const App: React.FC = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/react/test/base/src/pages/TabsScrollEffect.tsx b/packages/react/test/base/src/pages/TabsScrollEffect.tsx new file mode 100644 index 00000000000..de1a15e4cb8 --- /dev/null +++ b/packages/react/test/base/src/pages/TabsScrollEffect.tsx @@ -0,0 +1,100 @@ +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonLabel, + IonPage, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonTabs, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import React from 'react'; +import { Navigate, Route } from 'react-router'; + +const HomePage: React.FC = () => ( + + + + Home + + + Child + + + Child with tabBarScrollEffect + + + + + +
Home Content
+
+
+); + +interface ChildPageProps { + testId: string; + tabBarScrollEffect?: boolean; +} + +const ChildPage: React.FC = ({ testId, tabBarScrollEffect }) => ( + + + + + + + Child + + + +
Child Content
+
+
+); + +const OtherPage: React.FC = () => ( + + + + Other + + + +
Other Content
+
+
+); + +const TabsScrollEffect: React.FC = () => { + // Open the page with ?effect=fade to use the fade scroll effect instead of hide + const effect = new URLSearchParams(window.location.search).get('effect') === 'fade' ? 'fade' : 'hide'; + + return ( + + + } /> + } /> + } /> + } /> + } /> + + + + + Home + + + Other + + + + ); +}; + +export default TabsScrollEffect; diff --git a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts index 2d8b4302cd0..19a16597c69 100644 --- a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts @@ -95,4 +95,101 @@ describe('IonTabs', () => { cy.get('@consoleLog').should('be.calledWith', 'onIonTabsDidChange:', 'tab2'); }); }); + + /** + * Verifies the scroll effect of a tab bar in tabs with a router outlet. + * + * @see https://github.com/ionic-team/ionic-framework/issues/26510 + */ + describe('Tab bar scrollEffect', () => { + const getScrollTop = ($content: JQuery) => + $content[0].shadowRoot!.querySelector('.inner-scroll')!.scrollTop; + + // The tab bar starts following a page once it is visible, so wait for that before scrolling + const scrollToBottom = (pageTestId: string) => { + const selector = `[data-testid="${pageTestId}"] ion-content`; + + cy.get(selector).should('have.class', 'content-tab-bar-hide-scroll-partner'); + cy.get(selector).then(($content) => ($content[0] as HTMLIonContentElement).scrollToBottom(0)); + cy.get(selector).should(($content) => expect(getScrollTop($content)).to.be.greaterThan(0)); + }; + + describe('hide', () => { + beforeEach(() => { + cy.visit('/tabs-scroll-effect/home'); + }); + + it('should hide the tab bar when scrolling down the root page of a tab', () => { + scrollToBottom('home-page'); + + cy.get('ion-tab-bar').should('have.class', 'tab-bar-scroll-hidden'); + }); + + it('should hide the tab bar when scrolling down after switching tabs', () => { + cy.get('[data-testid="other-tab"]').click(); + cy.get('[data-testid="other-page"]').should('be.visible'); + + scrollToBottom('other-page'); + + cy.get('ion-tab-bar').should('have.class', 'tab-bar-scroll-hidden'); + }); + + it('should not hide the tab bar when scrolling down a page that can go back', () => { + cy.get('[data-testid="go-child"]').click(); + cy.get('[data-testid="child-page"]').should('be.visible'); + + // The tab bar stopped following the root page and does not follow the child page + cy.get('[data-testid="home-page"] ion-content').should('not.have.class', 'content-tab-bar-hide-scroll-partner'); + cy.get('[data-testid="child-page"] ion-content').should( + 'not.have.class', + 'content-tab-bar-hide-scroll-partner' + ); + cy.get('[data-testid="child-page"] ion-content').then(($content) => + ($content[0] as HTMLIonContentElement).scrollToBottom(0) + ); + cy.get('[data-testid="child-page"] ion-content').should(($content) => + expect(getScrollTop($content)).to.be.greaterThan(0) + ); + + cy.get('ion-tab-bar').should('not.have.class', 'tab-bar-scroll-hidden'); + }); + + it('should hide the tab bar on a page that can go back when tabBarScrollEffect is true', () => { + cy.get('[data-testid="go-child-effect"]').click(); + cy.get('[data-testid="child-effect-page"]').should('be.visible'); + + scrollToBottom('child-effect-page'); + + cy.get('ion-tab-bar').should('have.class', 'tab-bar-scroll-hidden'); + }); + + it('should show the tab bar when navigating to a page that can go back', () => { + scrollToBottom('home-page'); + cy.get('ion-tab-bar').should('have.class', 'tab-bar-scroll-hidden'); + + cy.get('[data-testid="go-child"]').click(); + cy.get('[data-testid="child-page"]').should('be.visible'); + + cy.get('ion-tab-bar').should('not.have.class', 'tab-bar-scroll-hidden'); + }); + }); + + describe('fade', () => { + beforeEach(() => { + cy.visit('/tabs-scroll-effect/home?effect=fade'); + }); + + const getOpacityScale = ($tabBar: JQuery) => $tabBar[0].style.getPropertyValue('--opacity-scale'); + + it('should fade the tab bar background out when the content is scrolled to the end', () => { + cy.get('ion-tab-bar').should(($tabBar) => expect(getOpacityScale($tabBar)).to.equal('1')); + + cy.get('[data-testid="home-page"] ion-content').then(($content) => + ($content[0] as HTMLIonContentElement).scrollToBottom(0) + ); + + cy.get('ion-tab-bar').should(($tabBar) => expect(getOpacityScale($tabBar)).to.equal('0')); + }); + }); + }); }); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index a6ef271d432..2366851b257 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -318,10 +318,13 @@ export const IonContent: StencilVueComponent = /*@__PURE__*/ def 'scrollX', 'scrollY', 'scrollEvents', + 'tabBarScrollEffect', + 'ionTabBarScrollEffectChange', 'ionScrollStart', 'ionScroll', 'ionScrollEnd' ], [ + 'ionTabBarScrollEffectChange', 'ionScrollStart', 'ionScroll', 'ionScrollEnd' diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index 6419cfa4645..9c986cdae85 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -226,6 +226,32 @@ const routes: Array = [ } ] }, + { + path: '/tab-bar-scroll-effect/', + component: () => import('@/views/tab-bar-scroll-effect/TabBarScrollEffect.vue'), + children: [ + { + path: '', + redirect: '/tab-bar-scroll-effect/home' + }, + { + path: 'home', + component: () => import('@/views/tab-bar-scroll-effect/Home.vue') + }, + { + path: 'home/child', + component: () => import('@/views/tab-bar-scroll-effect/Child.vue') + }, + { + path: 'home/child-effect', + component: () => import('@/views/tab-bar-scroll-effect/ChildEffect.vue') + }, + { + path: 'other', + component: () => import('@/views/tab-bar-scroll-effect/Other.vue') + } + ] + }, { path: '/tabs-similar-prefixes/', component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'), diff --git a/packages/vue/test/base/src/views/tab-bar-scroll-effect/Child.vue b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Child.vue new file mode 100644 index 00000000000..3806eb53850 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Child.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-bar-scroll-effect/ChildEffect.vue b/packages/vue/test/base/src/views/tab-bar-scroll-effect/ChildEffect.vue new file mode 100644 index 00000000000..8abf8ed55a2 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-bar-scroll-effect/ChildEffect.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-bar-scroll-effect/Home.vue b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Home.vue new file mode 100644 index 00000000000..39b0f2d34c7 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Home.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-bar-scroll-effect/Other.vue b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Other.vue new file mode 100644 index 00000000000..5e118a98498 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-bar-scroll-effect/Other.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-bar-scroll-effect/TabBarScrollEffect.vue b/packages/vue/test/base/src/views/tab-bar-scroll-effect/TabBarScrollEffect.vue new file mode 100644 index 00000000000..83a00c34d2f --- /dev/null +++ b/packages/vue/test/base/src/views/tab-bar-scroll-effect/TabBarScrollEffect.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/playwright/tab-bar-scroll-effect.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tab-bar-scroll-effect.spec.ts new file mode 100644 index 00000000000..e266792cfab --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tab-bar-scroll-effect.spec.ts @@ -0,0 +1,114 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./utils/test-base"; +import { ionPageVisible, routerPush, tabClick } from "./utils/test-utils"; + +const contentOf = (page: Page, pageId: string) => + page.locator(`.ion-page[data-pageid="${pageId}"] ion-content`); + +/** + * The tab bar starts following a page once the page is visible, so wait for + * the effect to be set up on the content before scrolling it. + */ +const scrollToBottom = async (page: Page, pageId: string) => { + const content = contentOf(page, pageId); + await expect(content).toHaveClass(/content-tab-bar-hide-scroll-partner/); + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); +}; + +const getOpacityScale = (page: Page) => + page + .locator("ion-tab-bar") + .evaluate((el) => el.style.getPropertyValue("--opacity-scale")); + +test.describe("Tab Bar: scrollEffect=hide", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tab-bar-scroll-effect/home"); + await ionPageVisible(page, "tab-bar-scroll-effect-home"); + }); + + test("should hide the tab bar when scrolling down the first tab", async ({ + page, + }) => { + await scrollToBottom(page, "tab-bar-scroll-effect-home"); + + await expect(page.locator("ion-tab-bar")).toHaveClass( + /tab-bar-scroll-hidden/ + ); + }); + + test("should hide the tab bar when scrolling down after switching tabs", async ({ + page, + }) => { + await tabClick(page, "other"); + await ionPageVisible(page, "tab-bar-scroll-effect-other"); + + await scrollToBottom(page, "tab-bar-scroll-effect-other"); + + await expect(page.locator("ion-tab-bar")).toHaveClass( + /tab-bar-scroll-hidden/ + ); + }); + + test("should not hide the tab bar on a child page", async ({ page }) => { + await page.locator("#go-child").click(); + await ionPageVisible(page, "tab-bar-scroll-effect-child"); + + // The tab bar stopped following the first tab and does not follow the child page + await expect(contentOf(page, "tab-bar-scroll-effect-home")).not.toHaveClass( + /content-tab-bar-hide-scroll-partner/ + ); + await contentOf(page, "tab-bar-scroll-effect-child").evaluate( + (el: HTMLIonContentElement) => el.scrollToBottom(0) + ); + + await expect(page.locator("ion-tab-bar")).not.toHaveClass( + /tab-bar-scroll-hidden/ + ); + }); + + test("should hide the tab bar on a child page when tabBarScrollEffect is true", async ({ + page, + }) => { + await page.locator("#go-child-effect").click(); + await ionPageVisible(page, "tab-bar-scroll-effect-child-effect"); + + await scrollToBottom(page, "tab-bar-scroll-effect-child-effect"); + + await expect(page.locator("ion-tab-bar")).toHaveClass( + /tab-bar-scroll-hidden/ + ); + }); + + test("should show the tab bar when navigating to a child page", async ({ + page, + }) => { + const tabBar = page.locator("ion-tab-bar"); + + await scrollToBottom(page, "tab-bar-scroll-effect-home"); + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + + await routerPush(page, "/tab-bar-scroll-effect/home/child"); + await ionPageVisible(page, "tab-bar-scroll-effect-child"); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + }); +}); + +test.describe("Tab Bar: scrollEffect=fade", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tab-bar-scroll-effect/home?effect=fade"); + await ionPageVisible(page, "tab-bar-scroll-effect-home"); + }); + + test("should fade the tab bar background out at the end of the content", async ({ + page, + }) => { + await expect.poll(() => getOpacityScale(page)).toBe("1"); + + await contentOf(page, "tab-bar-scroll-effect-home").evaluate( + (el: HTMLIonContentElement) => el.scrollToBottom(0) + ); + + await expect.poll(() => getOpacityScale(page)).toBe("0"); + }); +});