feat(header, footer, tab-bar): add scrollEffect prop#31263
feat(header, footer, tab-bar): add scrollEffect prop#31263OS-susmitabhowmik wants to merge 15 commits into
Conversation
…rrect theme class usage
…orce footer priority
…llEffect in all modes
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
ShaneK
left a comment
There was a problem hiding this comment.
Hey, great work on this! I highlighted a few issues I found, some of them aren't as important as others, but still
| 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'; |
There was a problem hiding this comment.
The docstrings and PR say condense/fade work across all themes now, but only the TS gate got unlocked here. All the condense/fade styling still lives in header.ios.scss, and .header-md.header-collapse-condense { display: none } still hides the large-title header on md, so <ion-header scroll-effect="condense"> renders nothing there. fade doesn't reach the toolbar background on md/ionic either, since the --opacity-scale: inherit bridge is iOS-only. Only hide has cross-theme e2e coverage, which is why this slipped through. I think we either port the condense/fade styling to md/ionic (with e2e), or scope the "all themes" claim down to hide and keep condense/fade documented as iOS-only. Which way do you want to go?
| const ION_CONTENT_TAG_NAME = 'ION-CONTENT'; | ||
| export const ION_CONTENT_ELEMENT_SELECTOR = 'ion-content'; | ||
| export const ION_CONTENT_CLASS_SELECTOR = '.ion-content-scroll-host'; | ||
| export const ION_PAGE_ELEMENT_SELECTOR = 'ion-app,ion-page,.ion-page,page-inner'; |
There was a problem hiding this comment.
This hardcodes ion-app, but the header/footer code it replaces used config.get('appRootSelector', 'ion-app'). ion-content still resolves its page via that config (content.tsx#L539), so an app with a custom appRootSelector and no ion-page ancestor could land the header/footer and content on different page elements. The blast radius is narrow, but it's a silent drop of config support and it touches the existing collapse paths too, not just hide. Was that intentional? If not, I think we should compose the selector from the config value so content and header stay in sync.
| } | ||
| }; | ||
|
|
||
| private setupScrollEffectHide = async (contentEl: HTMLElement) => { |
There was a problem hiding this comment.
setupScrollEffectHide awaits getScrollElement before assigning this.scrollHideCtrl/this.resizeObserver, and it runs from componentDidUpdate, which fires on every keyboardVisible toggle. If two setups overlap (mostly at mount), the first controller and ResizeObserver get orphaned since teardown only tracks the latest reference, and it rebuilds the whole controller on every re-render regardless. Could we guard this with the promise-identity pattern already used for keyboardCtrlPromise in this file? Same thing applies to setupScrollEffectHide in ion-header.
| } | ||
|
|
||
| const condenseHeader = contentEl.querySelector('ion-header[collapse="condense"]') as HTMLElement | null; | ||
| const condenseHeader = contentEl.querySelector( |
There was a problem hiding this comment.
One thing to watch on the fade + condense pairing: setToolbarBackgroundOpacity early-returns on headerEl.collapse === 'fade' so the condense handler doesn't fight handleHeaderFade over --opacity-scale. That guard reads only collapse, which is undefined when the dev uses the new scrollEffect="fade", so a header migrated from collapse to scrollEffect loses the guard and gets both functions writing --opacity-scale. I think that check needs the same scrollEffect ?? collapse resolution used everywhere else, e.g. const effect = headerEl.scrollEffect ?? headerEl.collapse; if (effect === 'fade') return;.
| if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') { | ||
| private setupScrollEffect = async () => { | ||
| // When nested inside ion-footer, the footer owns the hide animation. | ||
| if (this.el.closest('ion-footer')) { |
There was a problem hiding this comment.
This bails for any ion-footer, but the double-animation only happens when the footer itself hides. A tab-bar in a plain footer with scroll-effect="hide" gets a silent no-op, and tab-bar-in-footer is a pretty common layout. Could we defer only when the footer resolves to hide (closest('ion-footer')?.scrollEffect === 'hide'), and/or printIonWarning when we drop the prop so it isn't silent?
| const { scrollEffect, collapse } = this; | ||
|
|
||
| if (collapse !== undefined && scrollEffect === undefined) { | ||
| printIonWarning( |
There was a problem hiding this comment.
This warning runs from checkCollapsibleFooter, which fires on both componentDidLoad and componentDidUpdate, and printIonWarning doesn't dedupe. The footer re-renders on every keyboard toggle (keyboardVisible), so a footer using collapse will spam this to the console. I think we should fire it once. While we're here, the other deprecation warnings lead with a bracketed tag and pass the element, e.g. [ion-col] - / [ion-picker-legacy] -, so worth matching: printIonWarning('[ion-footer] - The \collapse` property is deprecated. Use `scrollEffect` instead.', this.el). Same applies to ion-header`.
| const visibleZone = 80; | ||
| // Hides after 60px of continuous downward scrolling only, when scrolling up threashold should be 0px | ||
| const scrollThresholdHide = 60; | ||
| private setHidden(hidden: boolean) { |
There was a problem hiding this comment.
setHidden toggles tab-bar-scroll-hidden, inert, and aria-hidden imperatively, but render() already derives all three from the @State isHidden, so this looks redundant. Also worth noting ion-header/ion-footer implement the same hide feature with a plain imperative field instead of @State + render, so the feature ships two different ways across the three components. Could these converge on the declarative @State approach?
| return; | ||
| } | ||
|
|
||
| const isScrollingDown = currentScrollTop > lastScrollPosition; |
There was a problem hiding this comment.
Nit: the direction-change hysteresis here (the scrollPositionAtDirectionChange anchor plus SCROLL_HIDE_THRESHOLD travel) is the core of this controller and the easiest bit to misread, but it's uncommented while smaller mechanics nearby have notes. A short comment on the anchor, plus a line on the module doc about why both wheel and scroll are handled, would help the next reader. Up to you though!
Issue number: resolves internal
What is the current behavior?
ion-headerandion-footerhave acollapseprop for scroll-driven effects (condense,fade), but it only works on the iOS theme.ion-tab-barhas an unreleased hideOnScroll boolean prop that only works on the Ionic theme withexpand="compact".What is the new behavior?
scrollEffectstring enum prop toion-header,ion-footer, andion-tab-bar.scrollEffect="hide"slides the bar out of view when scrolling down and back in when scrolling up.scrollEffect="condense"andscrollEffect="fade"onion-header/ion-footerreplace the existingcollapsevalues and work across all themes.collapseprop onion-headerandion-footerwith a console warning directing developers to usescrollEffect.hideOnScrollprop fromion-tab-bar.ion-tab-baris nested insideion-footer, the footer owns the hide animation to prevent double-animationDoes this introduce a breaking change?
Other information
Relevant Test Pages:
Header
Footer
Tab Bar