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..1a06060dea1 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. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. + */ + "scrollEffect"?: TabBarScrollEffect; /** * 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. Note: `"hide"` is ignored when the tab bar is nested inside an `ion-footer` to avoid leaving an empty footer visible on screen. Set `scroll-effect="hide"` on the footer instead so the entire footer hides together. + */ + "scrollEffect"?: TabBarScrollEffect; /** * 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..2493a39a125 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 extends behind the header +// (offset-top = header height), 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..c2f6ba20e1b 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -3,10 +3,14 @@ import { Component, Element, Host, Prop, State, h } 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,28 @@ export class Footer implements ComponentInterface { private contentScrollCallback?: () => void; private keyboardCtrl: KeyboardController | null = null; private keyboardCtrlPromise: Promise | null = null; + private scrollHideCtrl?: ScrollHideController; + private scrollHideCtrlPromise: Promise | null = null; + private hasWarnedCollapse = false; + private activeEffect?: string; @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({ reflect: true }) 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 +104,8 @@ export class Footer implements ComponentInterface { } disconnectedCallback() { + this.destroyCollapsibleFooter(); + if (this.keyboardCtrlPromise) { this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); this.keyboardCtrlPromise = null; @@ -97,28 +117,73 @@ export class Footer implements ComponentInterface { } } - private checkCollapsibleFooter = () => { - const theme = getIonTheme(this); - if (theme !== 'ios') { + 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 effect = scrollEffect ?? collapse; + + // Skip teardown/rebuild if the effect hasn't changed. + // This prevents keyboard toggles (which trigger re-renders via + // @State keyboardVisible) from destroying the scroll controller + // and resetting isHidden. + if (effect === this.activeEffect) { return; } - const { collapse } = this; - const hasFade = collapse === 'fade'; + const hasHide = effect === 'hide'; + const hasFade = effect === '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); + this.activeEffect = effect; + 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; } this.setupFadeFooter(contentEl); + this.activeEffect = effect; + } + }; + + private setupScrollEffectHide = async (contentEl: HTMLElement) => { + const promise = createScrollHideController(contentEl, { + el: this.el, + cssVar: '--internal-footer-hide-height', + hiddenClass: 'footer-scroll-hidden', + contentPartnerClass: 'content-footer-hide-scroll-partner', + contentHiddenClass: 'content-footer-hide-scroll-hidden', + }); + this.scrollHideCtrlPromise = promise; + + const controller = await promise; + + if (this.scrollHideCtrlPromise === promise) { + this.scrollHideCtrlPromise = null; + this.scrollHideCtrl = controller; + } else { + controller.destroy(); } }; @@ -137,6 +202,14 @@ export class Footer implements ComponentInterface { }; private destroyCollapsibleFooter() { + this.activeEffect = undefined; + this.scrollHideCtrlPromise = null; + + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; + } + if (this.scrollEl && this.contentScrollCallback) { this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); this.contentScrollCallback = undefined; @@ -144,8 +217,13 @@ export class Footer implements ComponentInterface { } 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 +240,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..751307ea5f7 --- /dev/null +++ b/core/src/components/footer/test/scroll-effect-hide/footer.e2e.ts @@ -0,0 +1,48 @@ +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', ''); + + // Verify the footer is visually off-screen via transform + const transform = await footer.evaluate((el) => getComputedStyle(el).transform); + expect(transform).not.toBe('none'); + }); + + 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..4d3b1323271 100644 --- a/core/src/components/header/header.common.scss +++ b/core/src/components/header/header.common.scss @@ -13,3 +13,177 @@ 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 an active condense + * header (the .header-collapse-condense class). + * + * We use opacity: 0 to avoid a layout shift. + * We use the class (not the attribute) because the class is only + * added when the condense effect is valid (large title present). + * The attribute alone is not sufficient since it would match even + * when the effect is guarded (e.g. no large title). + */ +ion-header:not(.header-collapse-main):has( + ~ ion-content ion-header.header-collapse-condense + ) { + opacity: 0; +} + +/** + * The deprecated collapse="condense" prop is iOS-only. + * On non-iOS themes, the condense header should be hidden since + * the JS won't activate it. Uses a class instead of an attribute + * selector because collapse is not a reflected prop, and framework + * proxies (Angular/Vue) bind it as a property. + */ +.header-collapse-condense-hidden { + display: none; +} + +// 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..29bff098707 100644 --- a/core/src/components/header/header.ios.scss +++ b/core/src/components/header/header.ios.scss @@ -33,140 +33,9 @@ --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; -} - +// This rule also exists in header.common.scss, but the iOS border rule +// above overrides it by source order. Repeating it here ensures the +// condense header's last toolbar has no border on iOS. .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. - */ -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 - ) { - opacity: 0; -} diff --git a/core/src/components/header/header.md.scss b/core/src/components/header/header.md.scss index 1d670924ff6..b690bf3616b 100644 --- a/core/src/components/header/header.md.scss +++ b/core/src/components/header/header.md.scss @@ -8,8 +8,30 @@ 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 { - display: none; + 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 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 { diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index e922a388605..dc6716c44ae 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -3,11 +3,15 @@ import { Component, Element, Host, Prop, h, 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,29 @@ export class Header implements ComponentInterface { private intersectionObserver?: IntersectionObserver; private collapsibleMainHeader?: HTMLElement; private inheritedAttributes: Attributes = {}; + private scrollHideCtrl?: ScrollHideController; + private scrollHideCtrlPromise: Promise | null = null; + private hasWarnedCollapse = false; + private activeEffect?: string; @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({ reflect: true }) 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 +100,46 @@ export class Header implements ComponentInterface { } private async checkCollapsibleHeader() { - const theme = getIonTheme(this); + 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 + ); + } - if (theme !== 'ios') { + const effect = scrollEffect ?? collapse; + + // Skip teardown/rebuild if the effect hasn't changed. + // This prevents re-renders from destroying the scroll controller + // and resetting isHidden. + if (effect === this.activeEffect) { return; } - const { collapse } = this; - const hasCondense = collapse === 'condense'; - const hasFade = collapse === 'fade'; + const hasHide = effect === 'hide'; + const hasCondense = effect === 'condense'; + const hasFade = effect === '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); + this.activeEffect = effect; + 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 +148,43 @@ 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; - + this.activeEffect = effect; + } 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); + this.activeEffect = effect; } } + private setupScrollEffectHide = async (contentEl: HTMLElement) => { + const promise = createScrollHideController(contentEl, { + el: this.el, + cssVar: '--internal-header-hide-height', + hiddenClass: 'header-scroll-hidden', + contentPartnerClass: 'content-header-hide-scroll-partner', + contentHiddenClass: 'content-header-hide-scroll-hidden', + }); + this.scrollHideCtrlPromise = promise; + + const controller = await promise; + + if (this.scrollHideCtrlPromise === promise) { + this.scrollHideCtrlPromise = null; + this.scrollHideCtrl = controller; + } else { + // A newer setup superseded this one — tear down the stale controller. + controller.destroy(); + } + }; + private setupFadeHeader = async (contentEl: HTMLElement, condenseHeader: HTMLElement | null) => { const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); @@ -137,6 +200,14 @@ export class Header implements ComponentInterface { }; private destroyCollapsibleHeader() { + this.activeEffect = undefined; + this.scrollHideCtrlPromise = null; + + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; + } + if (this.intersectionObserver) { this.intersectionObserver.disconnect(); this.intersectionObserver = undefined; @@ -164,10 +235,25 @@ export class Header implements ComponentInterface { this.scrollEl = await getScrollElement(contentEl); + /** + * The condense effect requires an ion-title with size="large" + * in the condense header. Without it, there is nothing to + * collapse into the main header toolbar. + */ + const hasLargeTitle = this.el.querySelector('ion-title[size="large"]') !== null; + if (!hasLargeTitle) { + printIonWarning( + '[ion-header] - The condense scroll effect requires an in the condense header.', + this.el + ); + return; + } + 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 +303,21 @@ 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 hasLargeTitle = this.el.querySelector('ion-title[size="large"]') !== null; + const hasHide = effect === 'hide'; + const hasCondense = effect === 'condense' && !isModeRestricted && hasLargeTitle; + const hasFade = effect === 'fade' && !isModeRestricted; + // The condense header should be hidden when + // - deprecated collapse prop is used on non-iOS (mode restricted), or + // - scrollEffect="condense" is set but no ion-title[size="large"] is present + const isHiddenCondense = effect === 'condense' && !hasCondense; // 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-no-large-title/header.e2e.ts b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts new file mode 100644 index 00000000000..f82073eb0c0 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts @@ -0,0 +1,51 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach( + ({ title, screenshot, config }) => { + test.describe(title('header: scroll-effect-condense without large title'), () => { + test('should not activate condense effect without a large title', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense-no-large-title', config); + + const condenseHeader = page.locator('#condenseHeader'); + + // Without a large title, the condense effect should not activate. + // The header should get the hidden class instead. + await expect(condenseHeader).toHaveClass(/header-collapse-condense-hidden/); + }); + + test('should keep main header visible without a large title', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense-no-large-title', config); + + const mainHeader = page.locator('#smallTitleHeader'); + + // The main header should remain visible (not hidden by the :has() ghost-hide rule) + await expect(mainHeader).toBeVisible(); + await expect(mainHeader).toHaveScreenshot(screenshot(`header-condense-no-large-title-main-visible-diff`)); + }); + + test('should not have visual regressions when no large title is present', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense-no-large-title', config); + + await expect(page).toHaveScreenshot(screenshot(`header-condense-no-large-title-initial-diff`)); + }); + + test('should not collapse on scroll without a large title', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense-no-large-title', config); + + const mainHeader = page.locator('#smallTitleHeader'); + const content = page.locator('ion-content'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + + // Wait a moment for any potential scroll handlers to fire + await page.waitForTimeout(500); + + // The main header should still be visible after scrolling + await expect(mainHeader).toBeVisible(); + }); + }); + } +); diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b8fd75f1db4 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5c50070fd41 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..bc90bd55e11 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..4e98cccd6c7 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..aa8a848ffc3 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..44f1574cf4d Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..614c0d1c8ed Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..183de35d3c1 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f5646fff188 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..8f5f1f02385 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..bcdfab98e3b Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..fd78a075519 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..90c491f8202 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e07fd13c1f4 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e3a732a349d Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ece2e1e9624 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3d69242247b Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..79c3b4dc570 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3327a246fac Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..bf50f1716aa Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d6bae9cd77a Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e8f53be82d0 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4aa461478c5 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1f86489a6c3 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-main-visible-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..271be27ba61 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e583264ce4a Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..66704823db0 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2216cb17507 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1940568b24f Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..319392f05fe Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9358f825c23 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..cf6f17b990c Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2b17c0b2019 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a24aecd0435 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d617be70c2e Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..cd690b0880c Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense-no-large-title/header.e2e.ts-snapshots/header-condense-no-large-title-scrolled-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense-no-large-title/index.html b/core/src/components/header/test/scroll-effect-condense-no-large-title/index.html new file mode 100644 index 00000000000..bd60a1dbf63 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-condense-no-large-title/index.html @@ -0,0 +1,79 @@ + + + + + Header - Scroll Effect Condense (No Large Title) + + + + + + + + + + + +
+ + + 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. +

+
+
+ + + Footer + + +
+
+ + 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..bd62c43fa79 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios', 'md', 'ionic-ios', 'ionic-md'], directions: ['ltr'] }).forEach( + ({ title, screenshot, 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 not have visual regressions with large title visible', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense', config); + + const largeTitleHeader = page.locator('#largeTitleHeader'); + await expect(largeTitleHeader).toHaveScreenshot(screenshot(`header-condense-large-title-initial-diff`)); + }); + + test('should not have visual regressions with large title collapsed', async ({ page }) => { + await page.goto('/src/components/header/test/scroll-effect-condense', config); + + const smallTitleHeader = page.locator('#smallTitleHeader'); + const content = page.locator('ion-content'); + + await content.evaluate(async (el: HTMLIonContentElement) => { + await el.scrollToBottom(); + }); + await page.locator('#largeTitleHeader.header-collapse-condense-inactive').waitFor(); + + await expect(smallTitleHeader).toHaveScreenshot(screenshot(`header-condense-large-title-collapsed-diff`)); + }); + + 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(); + + 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/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..90c491f8202 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e07fd13c1f4 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e3a732a349d Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ece2e1e9624 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3d69242247b Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..79c3b4dc570 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3327a246fac Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..bf50f1716aa Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d6bae9cd77a Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e8f53be82d0 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4aa461478c5 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1f86489a6c3 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-collapsed-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..739356d138a Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5f11c1d87ab Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..032f0c7e4a8 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-ios-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2db644bfd99 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..570caa17f2e Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..dc8d8a21793 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..461884d9407 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..02089d579f4 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..dd3fdaabde4 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9a6c6e1e52f Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..27abda998c2 Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2467d6bb23a Binary files /dev/null and b/core/src/components/header/test/scroll-effect-condense/header.e2e.ts-snapshots/header-condense-large-title-initial-diff-md-ltr-Mobile-Safari-linux.png differ 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..8951ce87205 --- /dev/null +++ b/core/src/components/header/test/scroll-effect-fade/header.e2e.ts @@ -0,0 +1,57 @@ +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'); + }); + + test('should work when scrollEffect is set as a property', 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'); + + // Remove the attribute and set via JS property to simulate framework binding + await header.evaluate((el: any) => { + el.removeAttribute('scroll-effect'); + el.scrollEffect = 'fade'; + }); + + await page.waitForChanges(); + + await expect(header).toHaveClass(/header-collapse-fade/); + + const initialOpacity = await header.evaluate((el: HTMLElement) => el.style.getPropertyValue('--opacity-scale')); + expect(initialOpacity).toBe('0'); + + 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..32b89fb469c --- /dev/null +++ b/core/src/components/header/test/scroll-effect-hide/header.e2e.ts @@ -0,0 +1,48 @@ +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', ''); + + // Verify the header is visually off-screen via transform + const transform = await header.evaluate((el) => getComputedStyle(el).transform); + expect(transform).not.toBe('none'); + }); + + 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..0d0859933aa 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -1,14 +1,18 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core'; -import { findIonContent, getScrollElement } from '@utils/content'; +import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; +import { findIonContent } 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,17 +31,14 @@ 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 scrollHideCtrlPromise: Promise | null = null; + private hasWarnedFooter = false; @Element() el!: HTMLElement; @State() keyboardVisible = false; - @State() scrollHidden = false; - /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -65,11 +66,20 @@ 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. + * + * Note: `"hide"` is ignored when the tab bar is nested inside an + * `ion-footer` to avoid leaving an empty footer visible on screen. + * Set `scroll-effect="hide"` on the footer instead so the entire + * footer hides together. */ - @Prop() hideOnScroll = false; + @Prop({ reflect: true }) scrollEffect?: TabBarScrollEffect; + @Watch('scrollEffect') + scrollEffectChanged() { + this.checkScrollEffect(); + } /** * If `true`, the tab bar will be translucent. @@ -111,19 +121,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() { @@ -138,6 +151,18 @@ export class TabBar implements ComponentInterface { } this.keyboardVisible = keyboardOpen; // trigger re-render by updating state + + // Toggle aria-hidden imperatively for the keyboard case. + // scroll-effect hide also sets aria-hidden via setHidden, + // so keeping a single imperative path avoids render() clearing + // the scroll-set attribute on re-render. + const shouldHide = keyboardOpen && this.el.getAttribute('slot') !== 'top'; + const isScrollHidden = this.scrollHideCtrl?.isHidden ?? false; + if (shouldHide || isScrollHidden) { + this.el.setAttribute('aria-hidden', 'true'); + } else { + this.el.removeAttribute('aria-hidden'); + } }); this.keyboardCtrlPromise = promise; @@ -157,6 +182,8 @@ export class TabBar implements ComponentInterface { } disconnectedCallback() { + this.destroyScrollEffect(); + if (this.keyboardCtrlPromise) { this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); this.keyboardCtrlPromise = null; @@ -166,80 +193,59 @@ 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); - } - - private async initScrollListener(contentEl: HTMLElement) { - const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); - - this.contentScrollCallback = () => { - readTask(() => { - const scrollTop = scrollEl.scrollTop; - const shouldHide = this.checkScrollStatus(scrollTop); - - if (shouldHide !== this.scrollHidden) { - writeTask(() => { - this.scrollHidden = shouldHide; - }); - } - - this.lastScrollTop = scrollTop; - }); - }; - - scrollEl.addEventListener('scroll', this.contentScrollCallback, { passive: true }); - } - - private destroyHideOnScroll() { - if (this.scrollEl && this.contentScrollCallback) { - this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); - this.contentScrollCallback = undefined; - } - } - - 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; - - if (scrollTop <= visibleZone) { - return false; - } + const promise = createScrollHideController(contentEl, { + el: this.el, + cssVar: '--internal-tab-bar-hide-height', + hiddenClass: 'tab-bar-scroll-hidden', + contentPartnerClass: 'content-tab-bar-hide-scroll-partner', + contentHiddenClass: 'content-tab-bar-hide-scroll-hidden', + shouldKeepAriaHidden: () => this.keyboardVisible, + }); + this.scrollHideCtrlPromise = promise; - const isScrollingDown = scrollTop > this.lastScrollTop; - const wasScrollingDown = this.lastScrollTop > this.scrollDirectionChangeTop; + const controller = await promise; - if (isScrollingDown !== wasScrollingDown) { - this.scrollDirectionChangeTop = this.lastScrollTop; + if (this.scrollHideCtrlPromise === promise) { + this.scrollHideCtrlPromise = null; + this.scrollHideCtrl = controller; + } else { + controller.destroy(); } + }; - const delta = Math.abs(scrollTop - this.scrollDirectionChangeTop); - const threshold = isScrollingDown ? scrollThresholdHide : 0; + private destroyScrollEffect() { + this.scrollHideCtrlPromise = null; - if (delta < threshold) { - return this.scrollHidden; + if (this.scrollHideCtrl) { + this.scrollHideCtrl.destroy(); + this.scrollHideCtrl = undefined; } - - return isScrollingDown; } private getShape(): string | undefined { @@ -259,7 +265,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'; @@ -267,13 +273,11 @@ export class TabBar implements ComponentInterface { return ( - 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..48d7ec45676 --- /dev/null +++ b/core/src/components/tab-bar/test/scroll-effect-hide/tab-bar.e2e.ts @@ -0,0 +1,48 @@ +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', ''); + + // Verify the tab bar is visually off-screen via transform + const transform = await tabBar.evaluate((el) => getComputedStyle(el).transform); + expect(transform).not.toBe('none'); + }); + + 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/components/title/title.md.scss b/core/src/components/title/title.md.scss index 4a126e2070b..7aaecfd9968 100644 --- a/core/src/components/title/title.md.scss +++ b/core/src/components/title/title.md.scss @@ -23,3 +23,15 @@ font-weight: normal; } + +// Material Design Title: Large +// -------------------------------------------------- + +:host(.title-large) { + width: 100%; + height: 100%; + + font-size: dynamic-font(28px); + + font-weight: 400; +} diff --git a/core/src/utils/scroll-hide-controller.ts b/core/src/utils/scroll-hide-controller.ts new file mode 100644 index 00000000000..449fc0e01db --- /dev/null +++ b/core/src/utils/scroll-hide-controller.ts @@ -0,0 +1,233 @@ +import { readTask, writeTask } from '@stencil/core'; + +import { getScrollElement } from './content'; + +const TOP_VISIBLE_THRESHOLD = 80; +const SCROLL_HIDE_THRESHOLD = 60; +const WHEEL_SUPPRESS_DURATION_MS = 80; +const SUPPRESS_SHOW_DURATION_MS = 400; + +export interface ScrollHideOptions { + /** The component's host element. */ + el: HTMLElement; + /** CSS variable name for the hide height, e.g. `--internal-header-hide-height`. */ + cssVar: string; + /** Class toggled on the component when hidden, e.g. `header-scroll-hidden`. */ + hiddenClass: string; + /** Class added to content when the scroll-hide effect is active, e.g. `content-header-hide-scroll-partner`. */ + contentPartnerClass: string; + /** Class toggled on content when hidden, e.g. `content-header-hide-scroll-hidden`. */ + contentHiddenClass: string; + /** + * Optional guard called before removing aria-hidden on show. + * Return `true` to keep aria-hidden (e.g. when the keyboard is open). + */ + shouldKeepAriaHidden?: () => boolean; +} + +export type ScrollHideController = { + /** Whether the component is currently hidden by the scroll effect. */ + readonly isHidden: boolean; + /** Destroy all listeners, observers, and clean up DOM state. */ + destroy: () => void; +}; + +/** + * 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 after scrolling 60px past that + * anchor, which prevents flickering on small or jittery movements. + * Showing is immediate on direction change. + * + * When `options` is provided, the controller also handles the DOM + * setup: ResizeObserver for height tracking, CSS variable read/write, + * content partner/hidden class toggling, inert/aria-hidden, and + * full teardown cleanup. This avoids duplicating the same setup + * across header, footer, and tab-bar. + * + * @internal + * @param contentEl The content element to resolve the scroll element from. + * @param options Configuration for DOM setup (classes, CSS vars, a11y). + */ +export const createScrollHideController = async ( + contentEl: HTMLElement, + options: ScrollHideOptions +): Promise => { + const { el, cssVar, hiddenClass, contentPartnerClass, contentHiddenClass, shouldKeepAriaHidden } = options; + + let controllerIsHidden = false; + let resizeObserver: ResizeObserver | undefined; + + // --- DOM setup: height tracking, classes, a11y --- + + const updateHideHeight = () => { + readTask(() => { + const heightPx = el.offsetHeight; + writeTask(() => { + el.style.setProperty(cssVar, `${heightPx}px`); + contentEl.style.setProperty(cssVar, `${heightPx}px`); + }); + }); + }; + + const setHidden = (hidden: boolean) => { + controllerIsHidden = hidden; + controller.isHidden = hidden; + el.classList.toggle(hiddenClass, hidden); + + if (hidden) { + el.setAttribute('inert', ''); + el.setAttribute('aria-hidden', 'true'); + } else { + el.removeAttribute('inert'); + if (!shouldKeepAriaHidden || !shouldKeepAriaHidden()) { + el.removeAttribute('aria-hidden'); + } + } + + contentEl.classList.toggle(contentHiddenClass, hidden); + }; + + // --- Scroll math --- + + const scrollEl = await getScrollElement(contentEl); + + let lastScrollPosition = 0; + let scrollPositionAtDirectionChange = 0; + let lastWheelEventTimestamp = 0; + let suppressShowUntil = 0; + + const commitHide = (hidden: boolean) => { + if (hidden) { + suppressShowUntil = Date.now() + SUPPRESS_SHOW_DURATION_MS; + } else { + suppressShowUntil = 0; + } + setHidden(hidden); + }; + + const handleWheel = (wheelEvent: WheelEvent) => { + lastWheelEventTimestamp = Date.now(); + + readTask(() => { + const currentScrollTop = scrollEl.scrollTop; + + if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { + if (controllerIsHidden) { + writeTask(() => commitHide(false)); + } + return; + } + + if (wheelEvent.deltaY < 0) { + scrollPositionAtDirectionChange = currentScrollTop; + if (controllerIsHidden) { + writeTask(() => commitHide(false)); + } + } else if (wheelEvent.deltaY > 0) { + const scrolledSinceDirectionChange = currentScrollTop - scrollPositionAtDirectionChange; + if (scrolledSinceDirectionChange >= SCROLL_HIDE_THRESHOLD && !controllerIsHidden) { + writeTask(() => commitHide(true)); + } + } + }); + }; + + const handleScroll = () => { + if (Date.now() - lastWheelEventTimestamp < WHEEL_SUPPRESS_DURATION_MS) { + return; + } + + readTask(() => { + const currentScrollTop = scrollEl.scrollTop; + + if (currentScrollTop <= TOP_VISIBLE_THRESHOLD) { + if (controllerIsHidden) { + writeTask(() => commitHide(false)); + } + lastScrollPosition = currentScrollTop; + return; + } + + 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 !== controllerIsHidden) { + if (!shouldHide && Date.now() < suppressShowUntil) { + return; + } + writeTask(() => commitHide(shouldHide)); + } + }); + }; + + // --- Setup --- + + updateHideHeight(); + + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => updateHideHeight()); + resizeObserver.observe(el); + } + + scrollEl.addEventListener('scroll', handleScroll, { passive: true }); + scrollEl.addEventListener('wheel', handleWheel as EventListener, { passive: true }); + + contentEl.classList.add(contentPartnerClass); + + // --- Controller --- + + const destroy = () => { + scrollEl.removeEventListener('scroll', handleScroll); + scrollEl.removeEventListener('wheel', handleWheel as EventListener); + + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = undefined; + } + + contentEl.classList.remove(contentPartnerClass, contentHiddenClass); + contentEl.style.removeProperty(cssVar); + + if (controllerIsHidden) { + el.classList.remove(hiddenClass); + el.removeAttribute('inert'); + el.removeAttribute('aria-hidden'); + controllerIsHidden = false; + controller.isHidden = false; + } + el.style.removeProperty(cssVar); + }; + + const controller = { + isHidden: false, + destroy, + }; + + return controller as ScrollHideController; +}; 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'