diff --git a/core/api.txt b/core/api.txt index 8147e65814d..8bc23e1a720 100644 --- a/core/api.txt +++ b/core/api.txt @@ -894,6 +894,7 @@ ion-fab-list,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-footer,none ion-footer,prop,collapse,"fade" | undefined,undefined,false,false ion-footer,prop,mode,"ios" | "md",undefined,false,false +ion-footer,prop,scrollEffect,"fade" | "hide" | undefined,undefined,false,false ion-footer,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-footer,prop,translucent,boolean,false,false,false @@ -937,6 +938,7 @@ ion-header,none ion-header,prop,collapse,"condense" | "fade" | undefined,undefined,false,false ion-header,prop,divider,boolean,false,false,false ion-header,prop,mode,"ios" | "md",undefined,false,false +ion-header,prop,scrollEffect,"condense" | "fade" | "hide" | undefined,undefined,false,false ion-header,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-header,prop,translucent,boolean,false,false,false @@ -2445,8 +2447,8 @@ ion-tab,method,setActive,setActive() => Promise 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,hideOnScroll,boolean,false,false,false ion-tab-bar,prop,mode,"ios" | "md",undefined,false,false +ion-tab-bar,prop,scrollEffect,"hide" | undefined,undefined,false,false 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 cf5a4c1bb68..87934778f9b 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -16,7 +16,9 @@ import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/bre import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +import { FooterScrollEffect } from "./components/footer/footer-interface"; import { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface"; +import { HeaderScrollEffect } from "./components/header/header-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -40,7 +42,7 @@ import { SegmentViewScrollEvent } from "./components/segment-view/segment-view-i import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; import { SelectModalOption } from "./components/select-modal/select-modal-interface"; import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; -import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; +import { TabBarChangedEventDetail, TabBarScrollEffect, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; import { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface"; import { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; @@ -55,7 +57,9 @@ export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/bre export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +export { FooterScrollEffect } from "./components/footer/footer-interface"; export { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface"; +export { HeaderScrollEffect } from "./components/header/header-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -79,7 +83,7 @@ export { SegmentViewScrollEvent } from "./components/segment-view/segment-view-i export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; export { SelectModalOption } from "./components/select-modal/select-modal-interface"; export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; -export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; +export { TabBarChangedEventDetail, TabBarScrollEffect, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; export { ToastButton, ToastDismissOptions, ToastLayout, ToastPosition, ToastPresentOptions, ToastSwipeGestureDirection } from "./components/toast/toast-interface"; export { ToggleChangeEventDetail } from "./components/toggle/toggle-interface"; @@ -1459,12 +1463,17 @@ export namespace Components { interface IonFooter { /** * Describes the scroll effect that will be applied to the footer. Only applies when the theme is `"ios"`. + * @deprecated Use `scrollEffect` instead. */ "collapse"?: 'fade'; /** * The mode determines the platform behaviors of the component. */ "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. + */ + "scrollEffect"?: FooterScrollEffect; /** * The theme determines the visual appearance of the component. */ @@ -1536,6 +1545,7 @@ export namespace Components { interface IonHeader { /** * Describes the scroll effect that will be applied to the header. Only applies when the theme is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) + * @deprecated Use `scrollEffect` instead. */ "collapse"?: 'condense' | 'fade'; /** @@ -1547,6 +1557,10 @@ export namespace Components { * The mode determines the platform behaviors of the component. */ "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. + */ + "scrollEffect"?: HeaderScrollEffect; /** * The theme determines the visual appearance of the component. */ @@ -4041,15 +4055,14 @@ export namespace Components { * @default 'full' */ "expand": 'compact' | 'full'; - /** - * If `true`, the tab bar will be hidden when the user scrolls down and shown when the user scrolls up. Only applies when the theme is `"ionic"` and `expand` is `"compact"`. - * @default false - */ - "hideOnScroll": boolean; /** * The mode determines the platform behaviors of the component. */ "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. + */ + "scrollEffect"?: TabBarScrollEffect; /** * The selected tab component */ @@ -7536,12 +7549,17 @@ declare namespace LocalJSX { interface IonFooter { /** * Describes the scroll effect that will be applied to the footer. Only applies when the theme is `"ios"`. + * @deprecated Use `scrollEffect` instead. */ "collapse"?: 'fade'; /** * The mode determines the platform behaviors of the component. */ "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. + */ + "scrollEffect"?: FooterScrollEffect; /** * The theme determines the visual appearance of the component. */ @@ -7609,6 +7627,7 @@ declare namespace LocalJSX { interface IonHeader { /** * Describes the scroll effect that will be applied to the header. Only applies when the theme is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) + * @deprecated Use `scrollEffect` instead. */ "collapse"?: 'condense' | 'fade'; /** @@ -7620,6 +7639,10 @@ declare namespace LocalJSX { * The mode determines the platform behaviors of the component. */ "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. + */ + "scrollEffect"?: HeaderScrollEffect; /** * The theme determines the visual appearance of the component. */ @@ -10208,17 +10231,16 @@ declare namespace LocalJSX { * @default 'full' */ "expand"?: 'compact' | 'full'; - /** - * If `true`, the tab bar will be hidden when the user scrolls down and shown when the user scrolls up. Only applies when the theme is `"ionic"` and `expand` is `"compact"`. - * @default false - */ - "hideOnScroll"?: boolean; /** * The mode determines the platform behaviors of the component. */ "mode"?: "ios" | "md"; "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. + */ + "scrollEffect"?: TabBarScrollEffect; /** * The selected tab component */ @@ -11015,6 +11037,7 @@ declare namespace LocalJSX { "side": 'start' | 'end' | 'top' | 'bottom'; } interface IonFooterAttributes { + "scrollEffect": FooterScrollEffect; "collapse": 'fade'; "translucent": boolean; } @@ -11028,6 +11051,7 @@ declare namespace LocalJSX { "fixed": boolean; } interface IonHeaderAttributes { + "scrollEffect": HeaderScrollEffect; "collapse": 'condense' | 'fade'; "divider": boolean; "translucent": boolean; @@ -11465,7 +11489,7 @@ declare namespace LocalJSX { interface IonTabBarAttributes { "color": Color; "selectedTab": string; - "hideOnScroll": boolean; + "scrollEffect": TabBarScrollEffect; "translucent": boolean; "expand": 'compact' | 'full'; "shape": 'soft' | 'round' | 'rectangular'; diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index 4024560ff41..e617246ced7 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -259,3 +259,58 @@ */ transform: translateZ(0); } + +// Content: Header/Footer/Tab Bar Scroll Effect Hide — Gap Compensation +// -------------------------------------------------- +// 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 fills the full viewport +// (offset-top = 0), so translateY would over-shift the content. +: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)); + + transition: height 350ms cubic-bezier(0.4, 0, 1, 1); +} + +// Tab bar gap compensation — only when content is NOT fullscreen +: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)); + + 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; + } +} diff --git a/core/src/components/footer/footer-interface.ts b/core/src/components/footer/footer-interface.ts new file mode 100644 index 00000000000..54239f578d1 --- /dev/null +++ b/core/src/components/footer/footer-interface.ts @@ -0,0 +1 @@ +export type FooterScrollEffect = 'hide' | 'fade'; diff --git a/core/src/components/footer/footer.ios.scss b/core/src/components/footer/footer.ios.scss index 3ec950baa49..4c2b51f967e 100644 --- a/core/src/components/footer/footer.ios.scss +++ b/core/src/components/footer/footer.ios.scss @@ -24,10 +24,3 @@ .footer-ios.ion-no-border ion-toolbar:first-of-type { --border-width: 0; } - -// iOS Footer - Collapse Fade -// -------------------------------------------------- - -.footer-collapse-fade ion-toolbar { - --opacity-scale: inherit; -} diff --git a/core/src/components/footer/footer.md.scss b/core/src/components/footer/footer.md.scss index 78cf5a29c48..49a3967ddc5 100644 --- a/core/src/components/footer/footer.md.scss +++ b/core/src/components/footer/footer.md.scss @@ -8,6 +8,15 @@ box-shadow: $footer-md-box-shadow; } +/** + * The fade effect fades the toolbar background via --opacity-scale, + * but box-shadow is not affected by that variable. Remove it so + * the shadow doesn't persist when the background is faded out. + */ +.footer-md.footer-collapse-fade { + box-shadow: none; +} + .footer-md.ion-no-border { box-shadow: none; } diff --git a/core/src/components/footer/footer.scss b/core/src/components/footer/footer.scss index 90aa68f993e..92d791e6af1 100644 --- a/core/src/components/footer/footer.scss +++ b/core/src/components/footer/footer.scss @@ -17,3 +17,34 @@ ion-footer { ion-footer.footer-toolbar-padding ion-toolbar:last-of-type { padding-bottom: var(--ion-safe-area-bottom, 0); } + +// Footer - Collapse Fade +// -------------------------------------------------- + +.footer-collapse-fade ion-toolbar { + --opacity-scale: inherit; +} + +// Footer - Scroll Effect Hide +// -------------------------------------------------- + +// Slide back into view when scrolling up — spring ease-out snaps the footer +// back into place quickly and settles smoothly. +ion-footer.footer-scroll-effect-hide { + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +// 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%); + + transition: transform 350ms cubic-bezier(0.4, 0, 1, 1); +} + +@media (prefers-reduced-motion: reduce) { + ion-footer.footer-scroll-effect-hide, + ion-footer.footer-scroll-effect-hide.footer-scroll-hidden { + transition: none; + } +} diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index 2039bf1c95d..fa4f7b770c4 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -1,12 +1,16 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Prop, State, h } from '@stencil/core'; +import { Component, Element, Host, Prop, State, h, readTask, writeTask } from '@stencil/core'; import { findIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import type { KeyboardController } from '@utils/keyboard/keyboard-controller'; import { createKeyboardController } from '@utils/keyboard/keyboard-controller'; +import { printIonWarning } from '@utils/logging'; +import type { ScrollHideController } from '@utils/scroll-hide-controller'; +import { createScrollHideController } from '@utils/scroll-hide-controller'; import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; +import type { FooterScrollEffect } from './footer-interface'; import { handleFooterFade } from './footer.utils'; /** @@ -26,14 +30,30 @@ export class Footer implements ComponentInterface { private contentScrollCallback?: () => void; private keyboardCtrl: KeyboardController | null = null; private keyboardCtrlPromise: Promise | null = null; + private scrollHideCtrl?: ScrollHideController; + private resizeObserver?: ResizeObserver; + private contentEl?: HTMLElement; + private isHidden = false; + private setupHidePromise: Promise | null = null; + private hasWarnedCollapse = false; @State() private keyboardVisible = false; @Element() el!: HTMLIonFooterElement; + /** + * 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. + */ + @Prop() scrollEffect?: FooterScrollEffect; + /** * Describes the scroll effect that will be applied to the footer. * Only applies when the theme is `"ios"`. + * + * @deprecated Use `scrollEffect` instead. */ @Prop() collapse?: 'fade'; @@ -86,6 +106,8 @@ export class Footer implements ComponentInterface { } disconnectedCallback() { + this.destroyCollapsibleFooter(); + if (this.keyboardCtrlPromise) { this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); this.keyboardCtrlPromise = null; @@ -97,22 +119,35 @@ export class Footer implements ComponentInterface { } } - private checkCollapsibleFooter = () => { - const theme = getIonTheme(this); - if (theme !== 'ios') { - return; + private checkCollapsibleFooter = async () => { + const { scrollEffect, collapse } = this; + + if (collapse !== undefined && scrollEffect === undefined && !this.hasWarnedCollapse) { + this.hasWarnedCollapse = true; + printIonWarning( + `[ion-footer] - The \`collapse\` property is deprecated. Use \`scrollEffect\` instead.\nExample: `, + this.el + ); } - const { collapse } = this; - const hasFade = collapse === 'fade'; + const hasHide = (scrollEffect ?? collapse) === 'hide'; + const hasFade = (scrollEffect ?? collapse) === 'fade'; this.destroyCollapsibleFooter(); - if (hasFade) { - 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; + 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; + + if (hasHide && contentEl) { + await this.setupScrollEffectHide(contentEl); + return; + } + // fade via the deprecated `collapse` prop is iOS-only. + // fade via the new `scrollEffect` prop works in all themes. + const isModeRestricted = scrollEffect === undefined && getIonTheme(this) !== 'ios'; + if (hasFade && !isModeRestricted) { if (!contentEl) { printIonContentErrorMsg(this.el); return; @@ -122,6 +157,69 @@ export class Footer implements ComponentInterface { } }; + private setupScrollEffectHide = async (contentEl: HTMLElement) => { + this.contentEl = contentEl; + + const promise = getScrollElement(contentEl); + this.setupHidePromise = promise; + + const scrollEl = await promise; + + /** + * Only assign if this is still the current promise. + * Otherwise, a new checkCollapsibleFooter has started or + * disconnectedCallback was called, so this setup is stale. + */ + if (this.setupHidePromise === promise) { + this.setupHidePromise = null; + + this.updateHideHeight(); + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.updateHideHeight()); + this.resizeObserver.observe(this.el); + } + + this.scrollHideCtrl = createScrollHideController(scrollEl, (hidden) => this.setHidden(hidden)); + + contentEl.classList.add('content-footer-hide-scroll-partner'); + } + }; + + /** + * Reads the footer's current height and writes it as a CSS variable + * on both the footer and the sibling content. The content uses this + * value to expand its scroll area when the footer hides (gap compensation). + */ + private updateHideHeight() { + readTask(() => { + const footerHeightPx = this.el.offsetHeight; + writeTask(() => { + this.el.style.setProperty('--internal-footer-hide-height', `${footerHeightPx}px`); + if (this.contentEl) { + this.contentEl.style.setProperty('--internal-footer-hide-height', `${footerHeightPx}px`); + } + }); + }); + } + + private setHidden(hidden: boolean) { + this.isHidden = hidden; + this.el.classList.toggle('footer-scroll-hidden', hidden); + + if (hidden) { + this.el.setAttribute('inert', ''); + this.el.setAttribute('aria-hidden', 'true'); + } else { + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); + } + + if (this.contentEl) { + this.contentEl.classList.toggle('content-footer-hide-scroll-hidden', hidden); + } + } + private setupFadeFooter = async (contentEl: HTMLElement) => { const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); @@ -141,11 +239,40 @@ export class Footer implements ComponentInterface { this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); this.contentScrollCallback = undefined; } + + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; + } + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + + if (this.contentEl) { + this.contentEl.classList.remove('content-footer-hide-scroll-partner', 'content-footer-hide-scroll-hidden'); + this.contentEl.style.removeProperty('--internal-footer-hide-height'); + this.contentEl = undefined; + } + + if (this.isHidden) { + this.el.classList.remove('footer-scroll-hidden'); + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); + this.isHidden = false; + } + this.el.style.removeProperty('--internal-footer-hide-height'); } render() { - const { translucent, collapse } = this; + const { translucent, scrollEffect, collapse } = this; const theme = getIonTheme(this); + const effect = scrollEffect ?? collapse; + // fade via the deprecated `collapse` prop is iOS-only. + const isModeRestricted = scrollEffect === undefined && theme !== 'ios'; + const hasHide = effect === 'hide'; + const hasFade = effect === 'fade' && !isModeRestricted; const tabs = this.el.closest('ion-tabs'); const tabBar = tabs?.querySelector(':scope > ion-tab-bar'); @@ -162,7 +289,8 @@ export class Footer implements ComponentInterface { [`footer-translucent-${theme}`]: translucent, ['footer-toolbar-padding']: !this.keyboardVisible && (!tabBar || tabBar.slot !== 'bottom'), - [`footer-collapse-${collapse}`]: collapse !== undefined, + 'footer-collapse-fade': hasFade, + 'footer-scroll-effect-hide': hasHide, }} > {theme === 'ios' && translucent && } diff --git a/core/src/components/footer/test/scroll-effect-fade/footer.e2e.ts b/core/src/components/footer/test/scroll-effect-fade/footer.e2e.ts new file mode 100644 index 00000000000..731f2a15b1a --- /dev/null +++ b/core/src/components/footer/test/scroll-effect-fade/footer.e2e.ts @@ -0,0 +1,31 @@ +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('footer: scroll-effect-fade'), () => { + test('should have the fade class on the footer', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-fade', config); + + const footer = page.locator('ion-footer'); + await expect(footer).toHaveClass(/footer-collapse-fade/); + }); + + test('should fade out toolbar background when scrolled to bottom', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-fade', config); + + const footer = page.locator('ion-footer'); + const content = page.locator('ion-content'); + + // Initially, opacity-scale should be 1 (toolbar background visible) + const initialOpacity = await footer.evaluate((el: HTMLElement) => el.style.getPropertyValue('--opacity-scale')); + expect(initialOpacity).toBe('1'); + + // Scroll to bottom — opacity-scale should be 0 (toolbar background hidden) + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.waitForChanges(); + + const scrolledOpacity = await footer.evaluate((el: HTMLElement) => el.style.getPropertyValue('--opacity-scale')); + expect(scrolledOpacity).toBe('0'); + }); + }); +}); diff --git a/core/src/components/footer/test/scroll-effect-fade/index.html b/core/src/components/footer/test/scroll-effect-fade/index.html new file mode 100644 index 00000000000..ef898fc1047 --- /dev/null +++ b/core/src/components/footer/test/scroll-effect-fade/index.html @@ -0,0 +1,80 @@ + + + + + Footer - Scroll Effect Fade + + + + + + + + + +
+ + + Header + + + +
+

Content

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. Donec bibendum at nisi vel finibus. Fusce id imperdiet odio. + Morbi orci ipsum, imperdiet ut iaculis sit amet, suscipit vulputate felis. Nullam arcu leo, volutpat eu + porttitor sed, fringilla et arcu. Pellentesque ac libero sapien. Quisque id dui velit. Mauris et pretium + tortor. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. Vivamus erat magna, elementum et dignissim nec, posuere eu ante. Praesent elementum, arcu + scelerisque venenatis sodales, turpis nulla aliquam urna, id hendrerit est orci et purus. Duis sem ipsum, + imperdiet eu elit id, tincidunt tempus sapien. Praesent tincidunt, sapien sed rhoncus euismod, lectus + velit ornare nunc, dapibus varius turpis leo ut magna. +

+

+ Nam quis quam id ante mattis pulvinar non sed mauris. Donec tempor sed nulla at semper. Vivamus ac nunc + bibendum, ullamcorper lacus quis, ornare massa. Cras gravida nibh risus, id sollicitudin eros ultricies + non. Integer velit massa, suscipit tincidunt rhoncus ut, lacinia et nisl. Maecenas volutpat ipsum blandit + sollicitudin lobortis. Suspendisse potenti. Cras non mi non arcu varius dapibus. Suspendisse maximus eget + justo a lobortis. Donec nulla ipsum, efficitur eget velit eget, varius rutrum quam. Nulla metus risus, + accumsan a tellus ac, faucibus blandit quam. Donec luctus, nisl ac ultricies ornare, nunc elit finibus + magna, id elementum ante urna congue ex. Cras condimentum nisi sollicitudin tortor vestibulum luctus. + Curabitur non ipsum et ex vestibulum congue. +

+

+ Maecenas rhoncus elit ut consectetur faucibus. Etiam sed sem sed mauris condimentum viverra sit amet at + nibh. Mauris bibendum at purus a cursus. Suspendisse potenti. Donec vel lacus ac odio euismod lacinia id + in urna. Donec commodo ipsum augue, at bibendum ex convallis suscipit. Nulla ac rhoncus odio. Aenean + elementum est nec arcu ultricies dignissim. +

+

+ Sed tincidunt bibendum massa, egestas bibendum est imperdiet vitae. Fusce dignissim consectetur ante a + fermentum. Morbi suscipit turpis sapien. Suspendisse eleifend sapien eget nunc mattis mattis. Phasellus + rhoncus sodales libero a imperdiet. Nam in vulputate lectus. Proin accumsan enim non nibh sagittis + ultricies. Nullam vitae ultricies nunc. Nullam ultrices dolor nec vehicula posuere. +

+
+
+ +
+ + Footer + +
+
+
+ + 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 new file mode 100644 index 00000000000..61ed1e0f641 --- /dev/null +++ b/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts @@ -0,0 +1,44 @@ +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('footer: scroll-effect-hide'), () => { + test('should have the scroll-effect-hide class when scrollEffect is set to hide', async ({ page }) => { + await page.goto('/src/components/footer/test/scroll-effect-hide', config); + + const footer = page.locator('ion-footer'); + await expect(footer).toHaveClass(/footer-scroll-effect-hide/); + }); + + test('should hide the footer when scrolling down', 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).not.toHaveClass(/footer-scroll-hidden/); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-footer.footer-scroll-hidden').waitFor(); + + await expect(footer).toHaveClass(/footer-scroll-hidden/); + await expect(footer).toHaveAttribute('inert', ''); + }); + + test('should show the footer again when scrolling back to the top', 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((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-footer.footer-scroll-hidden').waitFor(); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + await page.locator('ion-footer:not(.footer-scroll-hidden)').waitFor(); + + await expect(footer).not.toHaveClass(/footer-scroll-hidden/); + await expect(footer).not.toHaveAttribute('inert'); + }); + }); +}); diff --git a/core/src/components/footer/test/scroll-effect-hide/index.html b/core/src/components/footer/test/scroll-effect-hide/index.html new file mode 100644 index 00000000000..2bc04dbf1f7 --- /dev/null +++ b/core/src/components/footer/test/scroll-effect-hide/index.html @@ -0,0 +1,93 @@ + + + + + Footer - Scroll Effect Hide + + + + + + + + + + +
+ + + Header + + + +
+

Content

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. Donec bibendum at nisi vel finibus. Fusce id imperdiet odio. + Morbi orci ipsum, imperdiet ut iaculis sit amet, suscipit vulputate felis. Nullam arcu leo, volutpat eu + porttitor sed, fringilla et arcu. Pellentesque ac libero sapien. Quisque id dui velit. Mauris et pretium + tortor. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. Vivamus erat magna, elementum et dignissim nec, posuere eu ante. Praesent elementum, arcu + scelerisque venenatis sodales, turpis nulla aliquam urna, id hendrerit est orci et purus. Duis sem ipsum, + imperdiet eu elit id, tincidunt tempus sapien. Praesent tincidunt, sapien sed rhoncus euismod, lectus + velit ornare nunc, dapibus varius turpis leo ut magna. +

+

+ Nam quis quam id ante mattis pulvinar non sed mauris. Donec tempor sed nulla at semper. Vivamus ac nunc + bibendum, ullamcorper lacus quis, ornare massa. Cras gravida nibh risus, id sollicitudin eros ultricies + non. Integer velit massa, suscipit tincidunt rhoncus ut, lacinia et nisl. Maecenas volutpat ipsum blandit + sollicitudin lobortis. Suspendisse potenti. Cras non mi non arcu varius dapibus. Suspendisse maximus eget + justo a lobortis. Donec nulla ipsum, efficitur eget velit eget, varius rutrum quam. Nulla metus risus, + accumsan a tellus ac, faucibus blandit quam. Donec luctus, nisl ac ultricies ornare, nunc elit finibus + magna, id elementum ante urna congue ex. Cras condimentum nisi sollicitudin tortor vestibulum luctus. + Curabitur non ipsum et ex vestibulum congue. +

+

+ Maecenas rhoncus elit ut consectetur faucibus. Etiam sed sem sed mauris condimentum viverra sit amet at + nibh. Mauris bibendum at purus a cursus. Suspendisse potenti. Donec vel lacus ac odio euismod lacinia id + in urna. Donec commodo ipsum augue, at bibendum ex convallis suscipit. Nulla ac rhoncus odio. Aenean + elementum est nec arcu ultricies dignissim. +

+

+ Sed tincidunt bibendum massa, egestas bibendum est imperdiet vitae. Fusce dignissim consectetur ante a + fermentum. Morbi suscipit turpis sapien. Suspendisse eleifend sapien eget nunc mattis mattis. Phasellus + rhoncus sodales libero a imperdiet. Nam in vulputate lectus. Proin accumsan enim non nibh sagittis + ultricies. Nullam vitae ultricies nunc. Nullam ultrices dolor nec vehicula posuere. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. +

+
+
+ + + Footer + + +
+
+ + diff --git a/core/src/components/header/header-interface.ts b/core/src/components/header/header-interface.ts new file mode 100644 index 00000000000..44b883f2b09 --- /dev/null +++ b/core/src/components/header/header-interface.ts @@ -0,0 +1 @@ +export type HeaderScrollEffect = 'hide' | 'condense' | 'fade'; diff --git a/core/src/components/header/header.common.scss b/core/src/components/header/header.common.scss index e3364237921..9608da4498e 100644 --- a/core/src/components/header/header.common.scss +++ b/core/src/components/header/header.common.scss @@ -13,3 +13,165 @@ ion-header { ion-header ion-toolbar:first-of-type { padding-top: var(--ion-safe-area-top, 0); } + +// Header - Collapse Fade +// -------------------------------------------------- +.header-collapse-fade ion-toolbar { + --opacity-scale: inherit; +} + +/** + * Override styles applied during the page transition to prevent + * header flickering. + */ +.header-collapse-fade.header-transitioning ion-toolbar { + --background: transparent; + --border-style: none; +} + +// Header - Collapse Condense +// -------------------------------------------------- +.header-collapse-condense { + z-index: 9; +} + +.header-collapse-condense ion-toolbar { + position: sticky; + top: 0; +} + +.header-collapse-condense ion-toolbar:first-of-type { + /** + * The toolbar that contains the collapsible + * large title should not have safe area padding applied. + */ + padding-top: 0px; + + z-index: 1; +} + +/** + * Large title toolbar should just use the content background + * since it needs to blend in with the header above it. + */ +.header-collapse-condense ion-toolbar { + z-index: 0; +} + +.header-collapse-condense ion-toolbar:last-of-type { + --border-width: 0px; +} + +.header-collapse-condense ion-toolbar ion-searchbar { + padding-top: 0px; + padding-bottom: 13px; +} + +.header-collapse-main { + --opacity-scale: 1; +} + +.header-collapse-main ion-toolbar { + --opacity-scale: inherit; +} + +.header-collapse-main ion-toolbar.in-toolbar ion-title, +.header-collapse-main ion-toolbar.in-toolbar ion-buttons { + transition: all 0.2s ease-in-out; +} + +/** + * Large title toolbar should just use the content background + * since it needs to blend in with the header above it. + */ +.header-collapse-condense ion-toolbar, +/** + * Override styles applied during the page transition to prevent + * header flickering. + */ +.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar { + --background: var(--ion-background-color, #fff); +} + +/** + * Override styles applied during the page transition to prevent + * header flickering. + */ +.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar { + --border-style: none; + --opacity-scale: 1; +} + +.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-title, +.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-buttons.buttons-collapse { + opacity: 0; + pointer-events: none; +} + +/** + * There is a bug in Safari where changing + * the opacity of an element in a scrollable container + * while rubber-banding causes the scroll position + * to jump to the top + */ +.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-title, +.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-buttons.buttons-collapse { + visibility: hidden; +} + +/** + * The main header is only hidden once the collapsible large + * title is configured. As a result, if the main header loads + * before the collapsible large title is configured then the + * main header will be visible briefly before being hidden + * by the collapsible large title. + * + * The following selector ensures that any main header + * on a page with a collapsible large title is hidden + * before the collapsible large title is configured. + * Once the collapsible large title is configured the main + * header will have the ".header-collapse-main" class, and + * this selector will no longer apply. + * + * The :has(...) part of the selector ensures a couple things: + * 1. This will only apply within a page view since the content + * must be a subsequent-sibling of the header (~ ion-content). + * 2. This will only apply when that content has a collapse header + * (ion-header[collapse="condense"] or ion-header[scroll-effect="condense"]) + * + * We use opacity: 0 to avoid a layout shift. + * We target both the attribute and the class in the event that the attribute + * is not reflected on the host in some frameworks. + */ +ion-header:not(.header-collapse-main):has( + ~ ion-content ion-header[scroll-effect="condense"], + ~ ion-content ion-header.header-collapse-condense + ) { + opacity: 0; +} + +// Header - Scroll Effect Hide +// -------------------------------------------------- + +// Slide back into view when scrolling up — smooth deceleration eases the +// header back into place. +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%); + + transition: transform 200ms cubic-bezier(0.4, 0, 1, 1), opacity 300ms cubic-bezier(0.4, 0, 1, 1); + + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + ion-header.header-scroll-effect-hide, + ion-header.header-scroll-effect-hide.header-scroll-hidden { + transition: none; + } +} diff --git a/core/src/components/header/header.ios.scss b/core/src/components/header/header.ios.scss index 7dd79991a59..692569716ff 100644 --- a/core/src/components/header/header.ios.scss +++ b/core/src/components/header/header.ios.scss @@ -33,140 +33,15 @@ --border-width: 0; } -// iOS Header - Collapse Fade -// -------------------------------------------------- -.header-collapse-fade ion-toolbar { - --opacity-scale: inherit; -} - -/** - * Override styles applied during the page transition to prevent - * header flickering. - */ -.header-collapse-fade.header-transitioning ion-toolbar { - --background: transparent; - --border-style: none; -} - -// iOS Header - Collapse Condense -// -------------------------------------------------- -.header-collapse-condense { - z-index: 9; -} - -.header-collapse-condense ion-toolbar { - position: sticky; - top: 0; -} - -.header-collapse-condense ion-toolbar:first-of-type { - /** - * The toolbar that contains the collapsible - * large title should not have safe area padding applied. - */ - padding-top: 0px; - - z-index: 1; -} - -/** - * Large title toolbar should just use the content background - * since it needs to blend in with the header above it. - */ -.header-collapse-condense ion-toolbar { - z-index: 0; -} - .header-collapse-condense ion-toolbar:last-of-type { --border-width: 0px; } -.header-collapse-condense ion-toolbar ion-searchbar { - padding-top: 0px; - padding-bottom: 13px; -} - -.header-collapse-main { - --opacity-scale: 1; -} - -.header-collapse-main ion-toolbar { - --opacity-scale: inherit; -} - -.header-collapse-main ion-toolbar.in-toolbar ion-title, -.header-collapse-main ion-toolbar.in-toolbar ion-buttons { - transition: all 0.2s ease-in-out; -} - -/** - * Large title toolbar should just use the content background - * since it needs to blend in with the header above it. - */ -.header-collapse-condense ion-toolbar, -/** - * Override styles applied during the page transition to prevent - * header flickering. - */ -.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar { - --background: var(--ion-background-color, #fff); -} - -/** - * Override styles applied during the page transition to prevent - * header flickering. - */ -.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar { - --border-style: none; - --opacity-scale: 1; -} - -.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-title, -.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-buttons.buttons-collapse { - opacity: 0; - pointer-events: none; -} - -/** - * There is a bug in Safari where changing - * the opacity of an element in a scrollable container - * while rubber-banding causes the scroll position - * to jump to the top - */ -.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-title, -.header-collapse-condense-inactive.header-collapse-condense ion-toolbar.in-toolbar ion-buttons.buttons-collapse { - visibility: hidden; -} - /** - * The main header is only hidden once the collapsible large - * title is configured. As a result, if the main header loads - * before the collapsible large title is configured then the - * main header will be visible briefly before being hidden - * by the collapsible large title. - * - * The following selector ensures that any main header - * on a page with a collapsible large title is hidden - * before the collapsible large title is configured. - * Once the collapsible large title is configured the main - * header will have the ".header-collapse-main" class, and - * this selector will no longer apply. - * - * The :has(...) part of the selector ensures a couple things: - * 1. This will only apply within a page view since the content - * must be a subsequent-sibling of the header (~ ion-content). - * 2. This will only apply when that content has a collapse header (ion-header[collapse="condense"]) - * - * We use opacity: 0 to avoid a layout shift. - * We target both the attribute and the class in the event that the attribute - * is not reflected on the host in some frameworks. - * - * Both headers should be scoped to iOS mode otherwise an MD app that uses an - * iOS header may cause other MD headers to be unexpectedly hidden. + * Pre-configuration hiding for the deprecated collapse="condense" prop, + * which is iOS-only. The cross-theme equivalent for scrollEffect="condense" + * is in header.common.scss. */ -ion-header.header-ios:not(.header-collapse-main):has( - ~ ion-content ion-header.header-ios[collapse="condense"], - ~ ion-content ion-header.header-ios.header-collapse-condense - ) { +ion-header.header-ios:not(.header-collapse-main):has(~ ion-content ion-header.header-ios[collapse="condense"]) { opacity: 0; } diff --git a/core/src/components/header/header.md.scss b/core/src/components/header/header.md.scss index 1d670924ff6..e0646c5fd59 100644 --- a/core/src/components/header/header.md.scss +++ b/core/src/components/header/header.md.scss @@ -8,10 +8,42 @@ box-shadow: $header-md-box-shadow; } +/** + * The condense header should blend seamlessly with the + * main header above it, so remove the box-shadow. + */ .header-md.header-collapse-condense { + box-shadow: none; +} + +/** + * When the condense header is active (large title visible), + * the main header's box-shadow should be hidden so it blends + * with the condense header below it. + */ +.header-md.header-collapse-condense-inactive:not(.header-collapse-condense) { + box-shadow: none; +} + +/** + * The deprecated collapse="condense" attribute is iOS-only. + * On md, the condense header should be hidden since the JS + * won't activate it. Without this, the header renders as a + * normal visible header, adding unwanted height to the page. + */ +.header-md[collapse="condense"]:not(.header-collapse-condense) { display: none; } +/** + * The fade effect fades the toolbar background via --opacity-scale, + * but box-shadow is not affected by that variable. Remove it so + * the shadow doesn't persist when the background is faded out. + */ +.header-md.header-collapse-fade { + box-shadow: none; +} + .header-md.ion-no-border { box-shadow: none; } diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index e922a388605..2c5f4c006a0 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -1,13 +1,17 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Prop, h, writeTask } from '@stencil/core'; +import { Component, Element, Host, Prop, h, readTask, writeTask } from '@stencil/core'; import { findIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes } from '@utils/helpers'; +import { printIonWarning } from '@utils/logging'; +import type { ScrollHideController } from '@utils/scroll-hide-controller'; +import { createScrollHideController } from '@utils/scroll-hide-controller'; import { hostContext } from '@utils/theme'; import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; +import type { HeaderScrollEffect } from './header-interface'; import { cloneElement, createHeaderIndex, @@ -37,14 +41,31 @@ export class Header implements ComponentInterface { private intersectionObserver?: IntersectionObserver; private collapsibleMainHeader?: HTMLElement; private inheritedAttributes: Attributes = {}; + private scrollHideCtrl?: ScrollHideController; + private resizeObserver?: ResizeObserver; + private contentEl?: HTMLElement; + private isHidden = false; + private setupHidePromise: Promise | null = null; + private hasWarnedCollapse = false; @Element() el!: HTMLElement; + /** + * 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. + */ + @Prop() scrollEffect?: HeaderScrollEffect; + /** * Describes the scroll effect that will be applied to the header. * Only applies when the theme is `"ios"`. * * Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) + * + * @deprecated Use `scrollEffect` instead. */ @Prop() collapse?: 'condense' | 'fade'; @@ -81,24 +102,36 @@ export class Header implements ComponentInterface { } private async checkCollapsibleHeader() { - const theme = getIonTheme(this); - - if (theme !== 'ios') { - return; + const { scrollEffect, collapse } = this; + + if (collapse !== undefined && scrollEffect === undefined && !this.hasWarnedCollapse) { + this.hasWarnedCollapse = true; + printIonWarning( + `[ion-header] - The \`collapse\` property is deprecated. Use \`scrollEffect\` instead.\nExample: `, + this.el + ); } - const { collapse } = this; - const hasCondense = collapse === 'condense'; - const hasFade = collapse === 'fade'; + const hasHide = (scrollEffect ?? collapse) === 'hide'; + const hasCondense = (scrollEffect ?? collapse) === 'condense'; + const hasFade = (scrollEffect ?? collapse) === 'fade'; this.destroyCollapsibleHeader(); 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; + + if (hasHide && contentEl) { + await this.setupScrollEffectHide(contentEl); + return; + } - if (hasCondense) { - const pageEl = this.el.closest(`${appRootSelector},ion-page,.ion-page,page-inner`); - const contentEl = pageEl ? findIonContent(pageEl) : null; + // condense/fade via the deprecated `collapse` prop are iOS-only. + // condense/fade via the new `scrollEffect` prop work in all themes. + const isModeRestricted = scrollEffect === undefined && getIonTheme(this) !== 'ios'; + if (hasCondense && !isModeRestricted) { // Cloned elements are always needed in iOS transition writeTask(() => { const title = cloneElement('ion-title') as HTMLIonTitleElement; @@ -107,21 +140,84 @@ export class Header implements ComponentInterface { }); await this.setupCondenseHeader(contentEl, pageEl); - } else if (hasFade) { - const pageEl = this.el.closest(`${appRootSelector},ion-page,.ion-page,page-inner`); - const contentEl = pageEl ? findIonContent(pageEl) : null; - + } else if (hasFade && !isModeRestricted) { if (!contentEl) { printIonContentErrorMsg(this.el); return; } - const condenseHeader = contentEl.querySelector('ion-header[collapse="condense"]') as HTMLElement | null; + const condenseHeader = contentEl.querySelector( + 'ion-header[collapse="condense"],ion-header[scroll-effect="condense"]' + ) as HTMLElement | null; await this.setupFadeHeader(contentEl, condenseHeader); } } + private setupScrollEffectHide = async (contentEl: HTMLElement) => { + this.contentEl = contentEl; + + const promise = getScrollElement(contentEl); + this.setupHidePromise = promise; + + const scrollEl = await promise; + + /** + * Only assign if this is still the current promise. + * Otherwise, a new checkCollapsibleHeader has started or + * disconnectedCallback was called, so this setup is stale. + */ + if (this.setupHidePromise === promise) { + this.setupHidePromise = null; + + this.updateHideHeight(); + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.updateHideHeight()); + this.resizeObserver.observe(this.el); + } + + this.scrollHideCtrl = createScrollHideController(scrollEl, (hidden) => this.setHidden(hidden)); + + contentEl.classList.add('content-header-hide-scroll-partner'); + } + }; + + /** + * Reads the header's current height and writes it as a CSS variable + * on both the header and the sibling content. The content uses this + * value to shift up and expand its scroll area when the header hides + * (gap compensation). + */ + private updateHideHeight() { + readTask(() => { + const headerHeightPx = this.el.offsetHeight; + writeTask(() => { + this.el.style.setProperty('--internal-header-hide-height', `${headerHeightPx}px`); + if (this.contentEl) { + this.contentEl.style.setProperty('--internal-header-hide-height', `${headerHeightPx}px`); + } + }); + }); + } + + private setHidden(hidden: boolean) { + this.isHidden = hidden; + this.el.classList.toggle('header-scroll-hidden', hidden); + + if (hidden) { + this.el.setAttribute('inert', ''); + this.el.setAttribute('aria-hidden', 'true'); + } else { + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); + } + + if (this.contentEl) { + this.contentEl.classList.toggle('content-header-hide-scroll-hidden', hidden); + } + } + private setupFadeHeader = async (contentEl: HTMLElement, condenseHeader: HTMLElement | null) => { const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); @@ -147,10 +243,34 @@ export class Header implements ComponentInterface { this.contentScrollCallback = undefined; } + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; + } + if (this.collapsibleMainHeader) { this.collapsibleMainHeader.classList.remove('header-collapse-main'); this.collapsibleMainHeader = undefined; } + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + + if (this.contentEl) { + this.contentEl.classList.remove('content-header-hide-scroll-partner', 'content-header-hide-scroll-hidden'); + this.contentEl.style.removeProperty('--internal-header-hide-height'); + this.contentEl = undefined; + } + + if (this.isHidden) { + this.el.classList.remove('header-scroll-hidden'); + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); + this.isHidden = false; + } + this.el.style.removeProperty('--internal-header-hide-height'); } private async setupCondenseHeader(contentEl: HTMLElement | null, pageEl: Element | null) { @@ -165,9 +285,10 @@ export class Header implements ComponentInterface { this.scrollEl = await getScrollElement(contentEl); const headers = pageEl.querySelectorAll('ion-header'); - this.collapsibleMainHeader = Array.from(headers).find( - (header: HTMLIonHeaderElement) => header.collapse !== 'condense' - ) as HTMLElement | undefined; + this.collapsibleMainHeader = Array.from(headers).find((header) => { + const effect = header.scrollEffect ?? header.collapse; + return effect !== 'condense'; + }) as HTMLElement | undefined; if (!this.collapsibleMainHeader) { return; @@ -217,13 +338,19 @@ export class Header implements ComponentInterface { } render() { - const { translucent, inheritedAttributes, divider } = this; + const { translucent, inheritedAttributes, divider, scrollEffect, collapse } = this; const theme = getIonTheme(this); - const collapse = this.collapse || 'none'; - const isCondensed = collapse === 'condense'; - + const effect = scrollEffect ?? collapse; + // condense/fade via the deprecated `collapse` prop are iOS-only. + const isModeRestricted = scrollEffect === undefined && theme !== 'ios'; + const hasHide = effect === 'hide'; + const hasCondense = effect === 'condense' && !isModeRestricted; + const hasFade = effect === 'fade' && !isModeRestricted; + // A hidden condense header (deprecated collapse prop on non-iOS) should + // not have a landmark role since it's display:none. + const isHiddenCondense = effect === 'condense' && isModeRestricted; // banner role must be at top level, so remove role if inside a menu - const roleType = getRoleType(hostContext('ion-menu', this.el), isCondensed, theme); + const roleType = isHiddenCondense ? 'none' : getRoleType(hostContext('ion-menu', this.el)); return ( diff --git a/core/src/components/header/header.utils.ts b/core/src/components/header/header.utils.ts index 443913ec423..d56da34f63b 100644 --- a/core/src/components/header/header.utils.ts +++ b/core/src/components/header/header.utils.ts @@ -76,7 +76,7 @@ export const setToolbarBackgroundOpacity = (headerEl: HTMLIonHeaderElement, opac * has collapsed, so it is handled * by handleHeaderFade() */ - if (headerEl.collapse === 'fade') { + if ((headerEl.scrollEffect ?? headerEl.collapse) === 'fade') { return; } @@ -262,23 +262,15 @@ export const handleHeaderFade = (scrollEl: HTMLElement, baseEl: HTMLElement, con * Get the role type for the ion-header. * * @param isInsideMenu If ion-header is inside ion-menu. - * @param isCondensed If ion-header has collapse="condense". - * @param theme The current theme. - * @returns 'none' if inside ion-menu or if condensed in md - * theme, otherwise 'banner'. + * @returns 'none' if inside ion-menu, otherwise 'banner'. + * Condensed headers start as 'banner' and have their role + * toggled dynamically by setHeaderActive() on scroll. */ -export const getRoleType = (isInsideMenu: boolean, isCondensed: boolean, theme: 'ios' | 'md' | 'ionic') => { +export const getRoleType = (isInsideMenu: boolean) => { // If the header is inside a menu, it should not have the banner role. if (isInsideMenu) { return ROLE_NONE; } - /** - * Only apply role="none" to `md` & `ionic` theme condensed headers - * since the large header is never shown. - */ - if (isCondensed && theme !== 'ios') { - return ROLE_NONE; - } // Default to banner role. return ROLE_BANNER; }; diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts new file mode 100644 index 00000000000..997070f5ae4 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts @@ -0,0 +1,52 @@ +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-condense'), () => { + test('should have the condense class on the large title header', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense', config); + + const largeTitleHeader = page.locator('#largeTitleHeader'); + await expect(largeTitleHeader).toHaveClass(/header-collapse-condense/); + }); + + test('should hide small title from screen readers when collapsed', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense', config); + const smallTitleHeader = page.locator('#smallTitleHeader'); + const smallTitle = smallTitleHeader.locator('ion-title'); + const content = page.locator('ion-content'); + + await expect(smallTitle).toHaveAttribute('aria-hidden', 'true'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + await page.locator('#largeTitleHeader.header-collapse-condense-inactive').waitFor(); + + /** + * Playwright can't do .not.toHaveAttribute() because a value is expected, + * and toHaveAttribute can't accept a value of type null. + */ + const ariaHidden = await smallTitle.getAttribute('aria-hidden'); + expect(ariaHidden).toBeNull(); + }); + + test('should only have the banner role on the active header', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense', config); + const largeTitleHeader = page.locator('#largeTitleHeader'); + const smallTitleHeader = page.locator('#smallTitleHeader'); + const content = page.locator('ion-content'); + + await expect(largeTitleHeader).toHaveAttribute('role', 'banner'); + await expect(smallTitleHeader).toHaveAttribute('role', 'none'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + await page.locator('#largeTitleHeader.header-collapse-condense-inactive').waitFor(); + + await expect(largeTitleHeader).toHaveAttribute('role', 'none'); + await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); + }); + }); +}); diff --git a/core/src/components/header/test/scroll-effect-condense/index.html b/core/src/components/header/test/scroll-effect-condense/index.html new file mode 100644 index 00000000000..63386ee09b0 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-condense/index.html @@ -0,0 +1,90 @@ + + + + + Header - Scroll Effect Condense + + + + + + + + + + + +
+ + + Header + + + + + + Header + + +
+

Content

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. Donec bibendum at nisi vel finibus. Fusce id imperdiet odio. + Morbi orci ipsum, imperdiet ut iaculis sit amet, suscipit vulputate felis. Nullam arcu leo, volutpat eu + porttitor sed, fringilla et arcu. Pellentesque ac libero sapien. Quisque id dui velit. Mauris et pretium + tortor. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. Vivamus erat magna, elementum et dignissim nec, posuere eu ante. Praesent elementum, arcu + scelerisque venenatis sodales, turpis nulla aliquam urna, id hendrerit est orci et purus. Duis sem ipsum, + imperdiet eu elit id, tincidunt tempus sapien. Praesent tincidunt, sapien sed rhoncus euismod, lectus + velit ornare nunc, dapibus varius turpis leo ut magna. +

+

+ Nam quis quam id ante mattis pulvinar non sed mauris. Donec tempor sed nulla at semper. Vivamus ac nunc + bibendum, ullamcorper lacus quis, ornare massa. Cras gravida nibh risus, id sollicitudin eros ultricies + non. Integer velit massa, suscipit tincidunt rhoncus ut, lacinia et nisl. Maecenas volutpat ipsum blandit + sollicitudin lobortis. Suspendisse potenti. Cras non mi non arcu varius dapibus. Suspendisse maximus eget + justo a lobortis. Donec nulla ipsum, efficitur eget velit eget, varius rutrum quam. Nulla metus risus, + accumsan a tellus ac, faucibus blandit quam. Donec luctus, nisl ac ultricies ornare, nunc elit finibus + magna, id elementum ante urna congue ex. Cras condimentum nisi sollicitudin tortor vestibulum luctus. + Curabitur non ipsum et ex vestibulum congue. +

+

+ Maecenas rhoncus elit ut consectetur faucibus. Etiam sed sem sed mauris condimentum viverra sit amet at + nibh. Mauris bibendum at purus a cursus. Suspendisse potenti. Donec vel lacus ac odio euismod lacinia id + in urna. Donec commodo ipsum augue, at bibendum ex convallis suscipit. Nulla ac rhoncus odio. Aenean + elementum est nec arcu ultricies dignissim. +

+

+ Sed tincidunt bibendum massa, egestas bibendum est imperdiet vitae. Fusce dignissim consectetur ante a + fermentum. Morbi suscipit turpis sapien. Suspendisse eleifend sapien eget nunc mattis mattis. Phasellus + rhoncus sodales libero a imperdiet. Nam in vulputate lectus. Proin accumsan enim non nibh sagittis + ultricies. Nullam vitae ultricies nunc. Nullam ultrices dolor nec vehicula posuere. +

+
+
+ + + Footer + + +
+
+ + diff --git a/core/src/components/header/test/scroll-effect-fade/header.e2e.ts b/core/src/components/header/test/scroll-effect-fade/header.e2e.ts new file mode 100644 index 00000000000..94eb1bd1bff --- /dev/null +++ b/core/src/components/header/test/scroll-effect-fade/header.e2e.ts @@ -0,0 +1,31 @@ +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-fade'), () => { + test('should have the fade class on the header', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-fade', config); + + const header = page.locator('ion-header'); + await expect(header).toHaveClass(/header-collapse-fade/); + }); + + test('should fade in toolbar background on scroll', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-fade', config); + + const header = page.locator('ion-header'); + const content = page.locator('ion-content'); + + // Initially, opacity-scale should be 0 (toolbar background hidden) + const initialOpacity = await header.evaluate((el: HTMLElement) => el.style.getPropertyValue('--opacity-scale')); + expect(initialOpacity).toBe('0'); + + // Scroll to bottom — opacity-scale should be 1 (toolbar background visible) + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.waitForChanges(); + + const scrolledOpacity = await header.evaluate((el: HTMLElement) => el.style.getPropertyValue('--opacity-scale')); + expect(scrolledOpacity).toBe('1'); + }); + }); +}); diff --git a/core/src/components/header/test/scroll-effect-fade/index.html b/core/src/components/header/test/scroll-effect-fade/index.html new file mode 100644 index 00000000000..bf408b2a2a0 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-fade/index.html @@ -0,0 +1,81 @@ + + + + + Header - Scroll Effect Fade + + + + + + + + + + +
+ +
+ + Header + +
+ +
+

Content

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. Donec bibendum at nisi vel finibus. Fusce id imperdiet odio. + Morbi orci ipsum, imperdiet ut iaculis sit amet, suscipit vulputate felis. Nullam arcu leo, volutpat eu + porttitor sed, fringilla et arcu. Pellentesque ac libero sapien. Quisque id dui velit. Mauris et pretium + tortor. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. Vivamus erat magna, elementum et dignissim nec, posuere eu ante. Praesent elementum, arcu + scelerisque venenatis sodales, turpis nulla aliquam urna, id hendrerit est orci et purus. Duis sem ipsum, + imperdiet eu elit id, tincidunt tempus sapien. Praesent tincidunt, sapien sed rhoncus euismod, lectus + velit ornare nunc, dapibus varius turpis leo ut magna. +

+

+ Nam quis quam id ante mattis pulvinar non sed mauris. Donec tempor sed nulla at semper. Vivamus ac nunc + bibendum, ullamcorper lacus quis, ornare massa. Cras gravida nibh risus, id sollicitudin eros ultricies + non. Integer velit massa, suscipit tincidunt rhoncus ut, lacinia et nisl. Maecenas volutpat ipsum blandit + sollicitudin lobortis. Suspendisse potenti. Cras non mi non arcu varius dapibus. Suspendisse maximus eget + justo a lobortis. Donec nulla ipsum, efficitur eget velit eget, varius rutrum quam. Nulla metus risus, + accumsan a tellus ac, faucibus blandit quam. Donec luctus, nisl ac ultricies ornare, nunc elit finibus + magna, id elementum ante urna congue ex. Cras condimentum nisi sollicitudin tortor vestibulum luctus. + Curabitur non ipsum et ex vestibulum congue. +

+

+ Maecenas rhoncus elit ut consectetur faucibus. Etiam sed sem sed mauris condimentum viverra sit amet at + nibh. Mauris bibendum at purus a cursus. Suspendisse potenti. Donec vel lacus ac odio euismod lacinia id + in urna. Donec commodo ipsum augue, at bibendum ex convallis suscipit. Nulla ac rhoncus odio. Aenean + elementum est nec arcu ultricies dignissim. +

+

+ Sed tincidunt bibendum massa, egestas bibendum est imperdiet vitae. Fusce dignissim consectetur ante a + fermentum. Morbi suscipit turpis sapien. Suspendisse eleifend sapien eget nunc mattis mattis. Phasellus + rhoncus sodales libero a imperdiet. Nam in vulputate lectus. Proin accumsan enim non nibh sagittis + ultricies. Nullam vitae ultricies nunc. Nullam ultrices dolor nec vehicula posuere. +

+
+
+ + + Footer + + +
+
+ + 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 new file mode 100644 index 00000000000..a652ca89ffd --- /dev/null +++ b/core/src/components/header/test/scroll-effect-hide/header.e2e.ts @@ -0,0 +1,44 @@ +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'), () => { + test('should have the scroll-effect-hide class when scrollEffect is set to hide', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-hide', config); + + const header = page.locator('ion-header'); + await expect(header).toHaveClass(/header-scroll-effect-hide/); + }); + + test('should hide the header when scrolling down', 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).not.toHaveClass(/header-scroll-hidden/); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-header.header-scroll-hidden').waitFor(); + + await expect(header).toHaveClass(/header-scroll-hidden/); + await expect(header).toHaveAttribute('inert', ''); + }); + + test('should show the header again when scrolling back to the top', 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((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-header.header-scroll-hidden').waitFor(); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToTop(0)); + await page.locator('ion-header:not(.header-scroll-hidden)').waitFor(); + + await expect(header).not.toHaveClass(/header-scroll-hidden/); + await expect(header).not.toHaveAttribute('inert'); + }); + }); +}); diff --git a/core/src/components/header/test/scroll-effect-hide/index.html b/core/src/components/header/test/scroll-effect-hide/index.html new file mode 100644 index 00000000000..483c71f9587 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-hide/index.html @@ -0,0 +1,93 @@ + + + + + Header - Scroll Effect Hide + + + + + + + + + + +
+ + + Header + + + +
+

Content

+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. Donec bibendum at nisi vel finibus. Fusce id imperdiet odio. + Morbi orci ipsum, imperdiet ut iaculis sit amet, suscipit vulputate felis. Nullam arcu leo, volutpat eu + porttitor sed, fringilla et arcu. Pellentesque ac libero sapien. Quisque id dui velit. Mauris et pretium + tortor. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. Vivamus erat magna, elementum et dignissim nec, posuere eu ante. Praesent elementum, arcu + scelerisque venenatis sodales, turpis nulla aliquam urna, id hendrerit est orci et purus. Duis sem ipsum, + imperdiet eu elit id, tincidunt tempus sapien. Praesent tincidunt, sapien sed rhoncus euismod, lectus + velit ornare nunc, dapibus varius turpis leo ut magna. +

+

+ Nam quis quam id ante mattis pulvinar non sed mauris. Donec tempor sed nulla at semper. Vivamus ac nunc + bibendum, ullamcorper lacus quis, ornare massa. Cras gravida nibh risus, id sollicitudin eros ultricies + non. Integer velit massa, suscipit tincidunt rhoncus ut, lacinia et nisl. Maecenas volutpat ipsum blandit + sollicitudin lobortis. Suspendisse potenti. Cras non mi non arcu varius dapibus. Suspendisse maximus eget + justo a lobortis. Donec nulla ipsum, efficitur eget velit eget, varius rutrum quam. Nulla metus risus, + accumsan a tellus ac, faucibus blandit quam. Donec luctus, nisl ac ultricies ornare, nunc elit finibus + magna, id elementum ante urna congue ex. Cras condimentum nisi sollicitudin tortor vestibulum luctus. + Curabitur non ipsum et ex vestibulum congue. +

+

+ Maecenas rhoncus elit ut consectetur faucibus. Etiam sed sem sed mauris condimentum viverra sit amet at + nibh. Mauris bibendum at purus a cursus. Suspendisse potenti. Donec vel lacus ac odio euismod lacinia id + in urna. Donec commodo ipsum augue, at bibendum ex convallis suscipit. Nulla ac rhoncus odio. Aenean + elementum est nec arcu ultricies dignissim. +

+

+ Sed tincidunt bibendum massa, egestas bibendum est imperdiet vitae. Fusce dignissim consectetur ante a + fermentum. Morbi suscipit turpis sapien. Suspendisse eleifend sapien eget nunc mattis mattis. Phasellus + rhoncus sodales libero a imperdiet. Nam in vulputate lectus. Proin accumsan enim non nibh sagittis + ultricies. Nullam vitae ultricies nunc. Nullam ultrices dolor nec vehicula posuere. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas blandit, justo vitae rhoncus porttitor, + massa elit molestie nisl, ut tincidunt orci enim ac ante. Vestibulum tincidunt dignissim elit. Morbi + cursus hendrerit turpis, ut egestas tortor pulvinar sit amet. Interdum et malesuada fames ac ante ipsum + primis in faucibus. Phasellus faucibus consequat purus vel mollis. Ut ultricies elit nunc. Quisque + ultrices turpis vel augue auctor accumsan. +

+

+ Ut ultricies id augue vel aliquam. Etiam ornare finibus nisl, nec egestas urna. Nam pellentesque libero + nec justo tristique lacinia. In sit amet gravida metus, ac tincidunt mauris. Fusce sit amet tempus turpis. + Nulla ligula nunc, vestibulum quis quam in, feugiat aliquet nibh. Quisque in ante non nulla luctus gravida + vel at lacus. +

+
+
+ + + Footer + + +
+
+ + diff --git a/core/src/components/tab-bar/tab-bar-interface.ts b/core/src/components/tab-bar/tab-bar-interface.ts index ecb28326ef5..ba3a623350a 100644 --- a/core/src/components/tab-bar/tab-bar-interface.ts +++ b/core/src/components/tab-bar/tab-bar-interface.ts @@ -1,5 +1,7 @@ export type TabButtonLayout = 'icon-top' | 'icon-start' | 'icon-end' | 'icon-bottom' | 'icon-hide' | 'label-hide'; +export type TabBarScrollEffect = 'hide'; + 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 79bec564b43..53ae31437bd 100644 --- a/core/src/components/tab-bar/tab-bar.common.scss +++ b/core/src/components/tab-bar/tab-bar.common.scss @@ -62,3 +62,27 @@ padding-top: var(--ion-safe-area-top, 0); padding-bottom: 0; } + +// 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. +:host(.tab-bar-scroll-effect-hide) { + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +// 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%); + + transition: transform 350ms cubic-bezier(0.4, 0, 1, 1); +} + +@media (prefers-reduced-motion: reduce) { + :host(.tab-bar-scroll-effect-hide), + :host(.tab-bar-scroll-effect-hide.tab-bar-scroll-hidden) { + 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 c093cbf57d4..d966ebffc95 100644 --- a/core/src/components/tab-bar/tab-bar.ionic.scss +++ b/core/src/components/tab-bar/tab-bar.ionic.scss @@ -92,17 +92,17 @@ // Tab Bar Hide on Scroll // -------------------------------------------------- -:host(.tab-bar-hide-on-scroll) { +:host(.tab-bar-scroll-effect-hide) { transition: transform globals.$ion-transition-time-200 globals.$ion-transition-curve-spring; } -:host(.tab-bar-scroll-hidden) { +: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-350 globals.$ion-transition-curve-base; } -:host([slot="top"].tab-bar-scroll-hidden) { +:host([slot="top"].tab-bar-compact.tab-bar-scroll-hidden) { transform: translateY(-100%) translateX(calc(-50%)); } diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index 8fc1e5ecdd7..e912d9726af 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -3,12 +3,16 @@ import { Component, Element, Event, Host, Prop, State, Watch, h, readTask, write import { findIonContent, getScrollElement } from '@utils/content'; import type { KeyboardController } from '@utils/keyboard/keyboard-controller'; import { createKeyboardController } from '@utils/keyboard/keyboard-controller'; +import { printIonWarning } from '@utils/logging'; +import type { ScrollHideController } from '@utils/scroll-hide-controller'; +import { createScrollHideController } from '@utils/scroll-hide-controller'; import { createColorClasses } from '@utils/theme'; +import { config } from '../../global/config'; import { getIonTheme } from '../../global/ionic-global'; import type { Color } from '../../interface'; -import type { TabBarChangedEventDetail } from './tab-bar-interface'; +import type { TabBarChangedEventDetail, TabBarScrollEffect } from './tab-bar-interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. @@ -27,16 +31,16 @@ export class TabBar implements ComponentInterface { private keyboardCtrl: KeyboardController | null = null; private keyboardCtrlPromise: Promise | null = null; private didLoad = false; - private scrollEl?: HTMLElement; - private contentScrollCallback?: () => void; - private lastScrollTop = 0; - private scrollDirectionChangeTop = 0; + private scrollHideCtrl?: ScrollHideController; + private resizeObserver?: ResizeObserver; + private contentEl?: HTMLElement; + private setupHidePromise: Promise | null = null; + private hasWarnedFooter = false; @Element() el!: HTMLElement; @State() keyboardVisible = false; - - @State() scrollHidden = false; + private isHidden = false; /** * The color to use from your application's color palette. @@ -65,11 +69,15 @@ export class TabBar implements ComponentInterface { } /** - * If `true`, the tab bar will be hidden when the user scrolls down - * and shown when the user scrolls up. - * Only applies when the theme is `"ionic"` and `expand` is `"compact"`. + * 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. */ - @Prop() hideOnScroll = false; + @Prop() scrollEffect?: TabBarScrollEffect; + @Watch('scrollEffect') + scrollEffectChanged() { + this.checkScrollEffect(); + } /** * If `true`, the tab bar will be translucent. @@ -111,19 +119,22 @@ export class TabBar implements ComponentInterface { componentDidLoad() { this.ionTabBarLoaded.emit(); - // Set the flag to indicate the component has loaded - // This allows the watcher to emit changes from this point forward this.didLoad = true; - // Emit the initial selected tab after the component is fully loaded - // This ensures all child components (ion-tab-button) are ready if (this.selectedTab !== undefined) { this.ionTabBarChanged.emit({ tab: this.selectedTab, }); } - this.setupHideOnScroll(); + this.checkScrollEffect(); + } + + private checkScrollEffect() { + this.destroyScrollEffect(); + if (this.scrollEffect === 'hide') { + this.setupScrollEffect(); + } } async connectedCallback() { @@ -157,6 +168,8 @@ export class TabBar implements ComponentInterface { } disconnectedCallback() { + this.destroyScrollEffect(); + if (this.keyboardCtrlPromise) { this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); this.keyboardCtrlPromise = null; @@ -166,80 +179,118 @@ export class TabBar implements ComponentInterface { this.keyboardCtrl.destroy(); this.keyboardCtrl = null; } - - this.destroyHideOnScroll(); } - private setupHideOnScroll() { - const theme = getIonTheme(this); - if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') { + 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 + ); + } return; } - const footerEl = this.el.closest('ion-footer'); - const pageEl = footerEl?.closest('ion-page, .ion-page') ?? this.el.closest('ion-page, .ion-page'); + 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; if (!contentEl) { return; } - this.initScrollListener(contentEl); - } + this.contentEl = contentEl; - private async initScrollListener(contentEl: HTMLElement) { - const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); + const promise = getScrollElement(contentEl); + this.setupHidePromise = promise; - this.contentScrollCallback = () => { - readTask(() => { - const scrollTop = scrollEl.scrollTop; - const shouldHide = this.checkScrollStatus(scrollTop); + const scrollEl = await promise; - if (shouldHide !== this.scrollHidden) { - writeTask(() => { - this.scrollHidden = shouldHide; - }); - } + /** + * Only assign if this is still the current promise. + * Otherwise, a new checkScrollEffect has started or + * disconnectedCallback was called, so this setup is stale. + */ + if (this.setupHidePromise === promise) { + this.setupHidePromise = null; - this.lastScrollTop = scrollTop; - }); - }; + this.updateHideHeight(); - scrollEl.addEventListener('scroll', this.contentScrollCallback, { passive: true }); - } + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.updateHideHeight()); + this.resizeObserver.observe(this.el); + } - private destroyHideOnScroll() { - if (this.scrollEl && this.contentScrollCallback) { - this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); - this.contentScrollCallback = undefined; + this.scrollHideCtrl = createScrollHideController(scrollEl, (hidden) => this.setHidden(hidden)); + + contentEl.classList.add('content-tab-bar-hide-scroll-partner'); } + }; + + /** + * Reads the tab bar's current height and writes it as a CSS variable + * on both the tab bar and the sibling content. The content uses this + * value to expand its scroll area when the tab bar hides (gap compensation). + */ + private updateHideHeight() { + readTask(() => { + const tabBarHeightPx = this.el.offsetHeight; + writeTask(() => { + this.el.style.setProperty('--internal-tab-bar-hide-height', `${tabBarHeightPx}px`); + if (this.contentEl) { + this.contentEl.style.setProperty('--internal-tab-bar-hide-height', `${tabBarHeightPx}px`); + } + }); + }); } - private checkScrollStatus(scrollTop: number): boolean { - // Always visible within the first 80px of scroll - const visibleZone = 80; - // Hides after 60px of continuous downward scrolling only, when scrolling up threashold should be 0px - const scrollThresholdHide = 60; + private setHidden(hidden: boolean) { + this.isHidden = hidden; + this.el.classList.toggle('tab-bar-scroll-hidden', hidden); - if (scrollTop <= visibleZone) { - return false; + if (hidden) { + this.el.setAttribute('inert', ''); + this.el.setAttribute('aria-hidden', 'true'); + } else { + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); } - const isScrollingDown = scrollTop > this.lastScrollTop; - const wasScrollingDown = this.lastScrollTop > this.scrollDirectionChangeTop; + if (this.contentEl) { + this.contentEl.classList.toggle('content-tab-bar-hide-scroll-hidden', hidden); + } + } - if (isScrollingDown !== wasScrollingDown) { - this.scrollDirectionChangeTop = this.lastScrollTop; + private destroyScrollEffect() { + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; } - const delta = Math.abs(scrollTop - this.scrollDirectionChangeTop); - const threshold = isScrollingDown ? scrollThresholdHide : 0; + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } - if (delta < threshold) { - return this.scrollHidden; + if (this.contentEl) { + this.contentEl.classList.remove('content-tab-bar-hide-scroll-partner', 'content-tab-bar-hide-scroll-hidden'); + this.contentEl.style.removeProperty('--internal-tab-bar-hide-height'); + this.contentEl = undefined; } - return isScrollingDown; + this.el.style.removeProperty('--internal-tab-bar-hide-height'); + if (this.isHidden) { + this.el.classList.remove('tab-bar-scroll-hidden'); + this.el.removeAttribute('inert'); + this.el.removeAttribute('aria-hidden'); + this.isHidden = false; + } } private getShape(): string | undefined { @@ -259,7 +310,7 @@ export class TabBar implements ComponentInterface { } render() { - const { color, translucent, keyboardVisible, scrollHidden, expand, hideOnScroll } = this; + const { color, translucent, keyboardVisible, scrollEffect, expand } = this; const theme = getIonTheme(this); const shape = this.getShape(); const shouldHide = keyboardVisible && this.el.getAttribute('slot') !== 'top'; @@ -272,8 +323,7 @@ export class TabBar implements ComponentInterface { [theme]: true, 'tab-bar-translucent': translucent, 'tab-bar-hidden': shouldHide, - 'tab-bar-hide-on-scroll': hideOnScroll, - 'tab-bar-scroll-hidden': scrollHidden, + 'tab-bar-scroll-effect-hide': scrollEffect === 'hide', [`tab-bar-${expand}`]: true, [`tab-bar-${shape}`]: shape !== undefined, })} diff --git a/core/src/components/tab-bar/test/hide-on-scroll/index.html b/core/src/components/tab-bar/test/scroll-effect-hide-compact/index.html similarity index 64% rename from core/src/components/tab-bar/test/hide-on-scroll/index.html rename to core/src/components/tab-bar/test/scroll-effect-hide-compact/index.html index 3529d0b9930..8b3bed7c0eb 100644 --- a/core/src/components/tab-bar/test/hide-on-scroll/index.html +++ b/core/src/components/tab-bar/test/scroll-effect-hide-compact/index.html @@ -2,7 +2,7 @@ - Tab Bar - Hide on Scroll + Tab Bar - Scroll Effect Hide (Compact) - - - - - Home - + + + + Home + - - - Favorites - + + + Favorites + - - - Search - + + + Search + - - - Settings - - - + + + Settings + + diff --git a/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/index.html b/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/index.html new file mode 100644 index 00000000000..64d7fef6ad6 --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/index.html @@ -0,0 +1,58 @@ + + + + + Tab Bar - Scroll Effect Hide (Footer Priority) + + + + + + + + + + +
+ + + Header + + + +
+

Content

+

+ Scroll down. The tab bar should NOT hide because it is nested inside ion-footer which has no scrollEffect. + The footer controls hide behavior — the tab bar defers to it. +

+
+
+ + + + + + Home + + + + Favorites + + + + Search + + + +
+
+ + diff --git a/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/tab-bar.e2e.ts b/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/tab-bar.e2e.ts new file mode 100644 index 00000000000..2539f87899e --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide-footer-priority/tab-bar.e2e.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * When ion-tab-bar is nested inside ion-footer, the footer controls + * hide behavior. The tab bar's scrollEffect prop is ignored regardless + * of whether the parent footer has a scrollEffect set. + */ +configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tab-bar: scroll-effect-hide footer priority'), () => { + test('should not hide the tab bar when nested inside a footer without scrollEffect', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide-footer-priority', config); + + const tabBar = page.locator('#tabBar'); + const content = page.locator('ion-content'); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.waitForChanges(); + + // Tab bar should still not be hidden — footer takes priority + 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/index.html b/core/src/components/tab-bar/test/scroll-effect-hide/index.html new file mode 100644 index 00000000000..1f84fbe916a --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide/index.html @@ -0,0 +1,44 @@ + + + + + Tab Bar - Scroll Effect Hide + + + + + + + + + + +
+ + + Header + + + +
+

Content

+

Scroll down. The tab bar should hide when scrolling down and reappear when scrolling back up.

+
+
+ + + + Tab One + + + + Tab Two + + +
+
+ + 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 new file mode 100644 index 00000000000..bf84467b401 --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts @@ -0,0 +1,44 @@ +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-hide'), () => { + test('should have the scroll-effect-hide class when scrollEffect is set to hide', async ({ page }) => { + await page.goto('/src/components/tab-bar/test/scroll-effect-hide', config); + + const tabBar = page.locator('ion-tab-bar'); + await expect(tabBar).toHaveClass(/tab-bar-scroll-effect-hide/); + }); + + test('should hide the tab bar when scrolling down', 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 expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + + await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0)); + await page.locator('ion-tab-bar.tab-bar-scroll-hidden').waitFor(); + + await expect(tabBar).toHaveClass(/tab-bar-scroll-hidden/); + await expect(tabBar).toHaveAttribute('inert', ''); + }); + + test('should show the tab bar again when scrolling back to the top', 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((el: HTMLIonContentElement) => el.scrollToTop(0)); + await page.locator('ion-tab-bar:not(.tab-bar-scroll-hidden)').waitFor(); + + await expect(tabBar).not.toHaveClass(/tab-bar-scroll-hidden/); + await expect(tabBar).not.toHaveAttribute('inert'); + }); + }); +}); diff --git a/core/src/utils/scroll-hide-controller.ts b/core/src/utils/scroll-hide-controller.ts new file mode 100644 index 00000000000..380ab178874 --- /dev/null +++ b/core/src/utils/scroll-hide-controller.ts @@ -0,0 +1,144 @@ +import { readTask, writeTask } from '@stencil/core'; + +const TOP_VISIBLE_THRESHOLD = 80; +const SCROLL_HIDE_THRESHOLD = 60; +const WHEEL_SUPPRESS_DURATION_MS = 80; +const SUPPRESS_SHOW_DURATION_MS = 400; + +/** + * Creates a controller that manages scroll-based hide/show behavior + * for headers, footers, and tab bars. + * + * 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 reverses scroll direction, we save that position as + * an anchor. The bar only hides/shows after scrolling 60px past that + * anchor, which prevents flickering on small or jittery movements. + * + * @internal + * @param scrollEl The scrollable element to listen on. + * @param onHiddenChange Callback invoked inside a writeTask when visibility changes. + */ +export const createScrollHideController = ( + scrollEl: HTMLElement, + onHiddenChange: (hidden: boolean) => void +): ScrollHideController => { + let isHidden = false; + let lastScrollPosition = 0; + // Where the user last changed scroll direction. We measure distance + // from here to decide whether to commit to hiding or showing. + let scrollPositionAtDirectionChange = 0; + let lastWheelEventTimestamp = 0; + let suppressShowUntil = 0; + + const handleWheel = (wheelEvent: WheelEvent) => { + lastWheelEventTimestamp = Date.now(); + + readTask(() => { + const currentScrollTop = scrollEl.scrollTop; + + if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { + if (isHidden) { + writeTask(() => setHidden(false)); + } + return; + } + + if (wheelEvent.deltaY < 0) { + scrollPositionAtDirectionChange = currentScrollTop; + if (isHidden) { + writeTask(() => setHidden(false)); + } + } else if (wheelEvent.deltaY > 0) { + const scrolledSinceDirectionChange = currentScrollTop - scrollPositionAtDirectionChange; + if (scrolledSinceDirectionChange >= SCROLL_HIDE_THRESHOLD && !isHidden) { + writeTask(() => setHidden(true)); + } + } + }); + }; + + const handleScroll = () => { + // Suppress scroll events shortly after a wheel event — delta already processed via wheel + if (Date.now() - lastWheelEventTimestamp < WHEEL_SUPPRESS_DURATION_MS) { + return; + } + + readTask(() => { + const currentScrollTop = scrollEl.scrollTop; + + if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { + if (isHidden) { + writeTask(() => setHidden(false)); + } + lastScrollPosition = currentScrollTop; + return; + } + + // No movement — skip to avoid toggling state on duplicate scroll events + if (currentScrollTop === lastScrollPosition) { + return; + } + + const isScrollingDown = currentScrollTop > lastScrollPosition; + const wasScrollingDown = lastScrollPosition > scrollPositionAtDirectionChange; + + if (isScrollingDown !== wasScrollingDown) { + scrollPositionAtDirectionChange = lastScrollPosition; + } + + const scrolledSinceDirectionChange = Math.abs(currentScrollTop - scrollPositionAtDirectionChange); + const requiredScrollDistance = isScrollingDown ? SCROLL_HIDE_THRESHOLD : 0; + lastScrollPosition = currentScrollTop; + + if (scrolledSinceDirectionChange < requiredScrollDistance) { + return; + } + + const shouldHide = isScrollingDown; + if (shouldHide !== isHidden) { + // After hiding, the content height increases (CSS transition), which lowers + // max scrollTop and triggers a spurious upward-scroll event. Suppress "show" + // actions briefly to absorb that adjustment. + if (!shouldHide && Date.now() < suppressShowUntil) { + return; + } + writeTask(() => setHidden(shouldHide)); + } + }); + }; + + const setHidden = (hidden: boolean) => { + isHidden = hidden; + if (hidden) { + suppressShowUntil = Date.now() + SUPPRESS_SHOW_DURATION_MS; + } else { + suppressShowUntil = 0; + } + onHiddenChange(hidden); + }; + + scrollEl.addEventListener('scroll', handleScroll, { passive: true }); + scrollEl.addEventListener('wheel', handleWheel as EventListener, { passive: true }); + + const destroy = () => { + scrollEl.removeEventListener('scroll', handleScroll); + scrollEl.removeEventListener('wheel', handleWheel as EventListener); + + isHidden = false; + lastScrollPosition = 0; + scrollPositionAtDirectionChange = 0; + lastWheelEventTimestamp = 0; + suppressShowUntil = 0; + }; + + return { destroy }; +}; + +export type ScrollHideController = { + destroy: () => void; +}; diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 02001f41b45..d28d31dcd51 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -803,14 +803,14 @@ export declare interface IonFabList extends Components.IonFabList {} @ProxyCmp({ - inputs: ['collapse', 'mode', 'theme', 'translucent'] + inputs: ['collapse', 'mode', 'scrollEffect', 'theme', 'translucent'] }) @Component({ selector: 'ion-footer', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['collapse', 'mode', 'theme', 'translucent'], + inputs: ['collapse', 'mode', 'scrollEffect', 'theme', 'translucent'], }) export class IonFooter { protected el: HTMLIonFooterElement; @@ -891,14 +891,14 @@ export declare interface IonGrid extends Components.IonGrid {} @ProxyCmp({ - inputs: ['collapse', 'divider', 'mode', 'theme', 'translucent'] + inputs: ['collapse', 'divider', 'mode', 'scrollEffect', 'theme', 'translucent'] }) @Component({ selector: 'ion-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['collapse', 'divider', 'mode', 'theme', 'translucent'], + inputs: ['collapse', 'divider', 'mode', 'scrollEffect', 'theme', 'translucent'], }) export class IonHeader { protected el: HTMLIonHeaderElement; @@ -2414,14 +2414,14 @@ export declare interface IonTab extends Components.IonTab {} @ProxyCmp({ - inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] + inputs: ['color', 'expand', 'mode', 'scrollEffect', 'selectedTab', 'shape', 'theme', 'translucent'] }) @Component({ selector: 'ion-tab-bar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], + inputs: ['color', 'expand', 'mode', 'scrollEffect', 'selectedTab', 'shape', 'theme', 'translucent'], }) export class IonTabBar { protected el: HTMLIonTabBarElement; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 8ea4fbda890..f95f5cc6316 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -844,14 +844,14 @@ export declare interface IonFabList extends Components.IonFabList {} @ProxyCmp({ defineCustomElementFn: defineIonFooter, - inputs: ['collapse', 'mode', 'theme', 'translucent'] + inputs: ['collapse', 'mode', 'scrollEffect', 'theme', 'translucent'] }) @Component({ selector: 'ion-footer', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['collapse', 'mode', 'theme', 'translucent'], + inputs: ['collapse', 'mode', 'scrollEffect', 'theme', 'translucent'], standalone: true }) export class IonFooter { @@ -940,14 +940,14 @@ export declare interface IonGrid extends Components.IonGrid {} @ProxyCmp({ defineCustomElementFn: defineIonHeader, - inputs: ['collapse', 'divider', 'mode', 'theme', 'translucent'] + inputs: ['collapse', 'divider', 'mode', 'scrollEffect', 'theme', 'translucent'] }) @Component({ selector: 'ion-header', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['collapse', 'divider', 'mode', 'theme', 'translucent'], + inputs: ['collapse', 'divider', 'mode', 'scrollEffect', 'theme', 'translucent'], standalone: true }) export class IonHeader { @@ -2163,14 +2163,14 @@ export declare interface IonTab extends Components.IonTab {} @ProxyCmp({ defineCustomElementFn: defineIonTabBar, - inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] + inputs: ['color', 'expand', 'mode', 'scrollEffect', 'selectedTab', 'shape', 'theme', 'translucent'] }) @Component({ selector: 'ion-tab-bar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], + inputs: ['color', 'expand', 'mode', 'scrollEffect', 'selectedTab', 'shape', 'theme', 'translucent'], standalone: true }) export class IonTabBar { diff --git a/packages/react/src/components/react-component-lib/utils/index.tsx b/packages/react/src/components/react-component-lib/utils/index.tsx index cb63370df44..a66bd02de76 100644 --- a/packages/react/src/components/react-component-lib/utils/index.tsx +++ b/packages/react/src/components/react-component-lib/utils/index.tsx @@ -37,16 +37,7 @@ export const createForwardRef = (ReactComponent: any, dis }; forwardRef.displayName = displayName; - // Cast the render function to the type React.forwardRef already infers for it. - // React 18's `forwardRef` wraps the props in `PropsWithoutRef`, and since - // `PropType` is unconstrained TypeScript can't prove the round-trip is safe. - // The cast keeps the inferred component type intact without widening to `any`. - return React.forwardRef( - forwardRef as React.ForwardRefRenderFunction< - ElementType, - React.PropsWithoutRef> - > - ); + return React.forwardRef(forwardRef); }; export const defineCustomElement = (tagName: string, customElement: any) => { diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index a377c714433..e44eb1b1665 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -429,6 +429,7 @@ export const IonFabList: StencilVueComponent = /*@__PURE__*/ def export const IonFooter: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-footer', defineIonFooter, [ + 'scrollEffect', 'collapse', 'translucent' ]); @@ -451,6 +452,7 @@ export const IonGrid: StencilVueComponent = /*@__PURE__*/ defineCon export const IonHeader: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-header', defineIonHeader, [ + 'scrollEffect', 'collapse', 'divider', 'translucent'