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"); + }); +});