feat(header, footer, tab-bar): add scrollEffect prop#31263
feat(header, footer, tab-bar): add scrollEffect prop#31263OS-susmitabhowmik wants to merge 47 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
ShaneK
left a comment
There was a problem hiding this comment.
Hey! Thanks for fixing up those issues! I found some more things that were a bit concerning with this change, let me know if I'm off the mark on anything here!
| } | ||
| } | ||
|
|
||
| private setupScrollEffectHide = async (contentEl: HTMLElement) => { |
There was a problem hiding this comment.
setupHidePromise only gets nulled in the === promise success branch, but none of the destroy* methods or disconnectedCallback reset it, even though the guard comment says the disconnect case is handled. So if getScrollElement is still pending when the component disconnects (fast nav, tab switch) or scrollEffect switches away from hide, the stale promise still passes the identity check and we build a ScrollHideController + ResizeObserver and re-add the content partner classes, with nothing left to tear them down. I think setting this.setupHidePromise = null inside each destroy* method fixes it, since they run at the top of every checkCollapsible*/checkScrollEffect and in disconnectedCallback (matches the keyboardCtrlPromise handling and the pattern in components.md). Same thing applies to footer.tsx and tab-bar.tsx.
There was a problem hiding this comment.
Great catch! I set this.setupHidePromise = null inside each destroy method in this commit 416cf50
| 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.
scroll-effect="condense" opens up for all themes here, but I think it ends up a visual no-op on md? The JS clones the large title and toggles header-collapse-condense, but only title.ios.scss and title.ionic.scss define :host(.title-large), so on md the large title renders at normal size and there's nothing to collapse. The condense e2e only checks the class/aria-hidden/role, so it passes either way. Was md large-title styling meant to be part of this, or is condense-on-md intentionally out of scope for now? A per-theme screenshot test would lock down whichever way you want it. Let me know if I'm missing something!
There was a problem hiding this comment.
Thanks for catching this! Previously we didn't have a guard for the large title so the collapse was working. I added a check for the large title in this commit d976f01 and also added large title styling for the md theme in this commit d0ed544. I set the font size to match the large app bar for material design (https://m3.material.io/components/app-bars/specs) but will double check this with product.
| const hasHide = (scrollEffect ?? collapse) === 'hide'; | ||
| const hasFade = (scrollEffect ?? collapse) === 'fade'; | ||
|
|
||
| this.destroyCollapsibleFooter(); |
There was a problem hiding this comment.
checkCollapsibleFooter runs from componentDidUpdate, and keyboardVisible is @State, so every keyboard show/hide re-renders and this destroyCollapsibleFooter() resets isHidden, strips footer-scroll-hidden, then rebuilds the controller + ResizeObserver. In practice a scroll-effect="hide" footer that you've scrolled away pops back into view the moment you focus an input, and it repeats on every keyboard toggle (losing the scroll hysteresis each time). tab-bar dodges this by driving setup from @Watch('scrollEffect') + componentDidLoad. Could header/footer do the same, or early-return when the resolved effect and content element haven't changed?
| const { color, translucent, keyboardVisible, scrollEffect, expand } = this; | ||
| const theme = getIonTheme(this); | ||
| const shape = this.getShape(); | ||
| const shouldHide = keyboardVisible && this.el.getAttribute('slot') !== 'top'; |
There was a problem hiding this comment.
setHidden sets aria-hidden imperatively for the scroll effect, but render() also owns it for the keyboard case (aria-hidden={shouldHide ? 'true' : null} just below). When a scroll-hidden tab-bar re-renders with shouldHide=false (keyboard closing), Stencil emits aria-hidden={null} and clears it, so the bar ends up inert + off-screen but no longer aria-hidden. inert covers the a11y hiding so it's not a hard break, but it's two writers on one attribute, and header/footer never render aria-hidden at all. Could tab-bar route the keyboard aria-hidden through the same imperative path so there's a single writer? Up to you.
| * won't activate it. Without this, the header renders as a | ||
| * normal visible header, adding unwanted height to the page. | ||
| */ | ||
| .header-md[collapse="condense"]:not(.header-collapse-condense) { |
There was a problem hiding this comment.
One thing I wanted to double check on the deprecated path: collapse isn't a reflected @Prop, and the Angular/Vue proxies bind it as a property, so under [collapse]="'condense'" there's no collapse attribute on the host and .header-md[collapse="condense"] won't match, which I think leaves the ghost header visible on md. header.common.scss already calls this out and targets both the attribute and the class for that reason. Was keying off the attribute a deliberate choice here, or should this match a class the way the :has() rule does?
| private setupHideOnScroll() { | ||
| const theme = getIonTheme(this); | ||
| if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') { | ||
| private setupScrollEffect = async () => { |
There was a problem hiding this comment.
setupScrollEffect bails for any ancestor ion-footer, so <ion-footer><ion-tab-bar scroll-effect="hide"> where the footer has no scrollEffect (or fade) ends up hiding nothing, the tab-bar warns and returns and the footer isn't animating either. Letting the footer own the animation makes sense when the footer itself hides, but for a plain footer the prop just does nothing plus a console warning. Is that the intended final behavior? If so I think it's worth documenting on the prop rather than only warning at runtime, and if not, maybe only bail when the footer actually resolves to a hide effect.
There was a problem hiding this comment.
I added a comment to document this b44fc68. If only ion-tab-bar is animated to hide, the footer visibly remains behind leaving an empty gap at the bottom of the page.
| * same gesture. | ||
| * | ||
| * When the user reverses scroll direction, we save that position as | ||
| * an anchor. The bar only hides/shows after scrolling 60px past that |
There was a problem hiding this comment.
Nit: the module doc says the bar "only hides/shows after scrolling 60px past that anchor", but showing is immediate (requiredScrollDistance is 0 when scrolling up, and the wheel-up path shows with no distance check). Might be worth dropping the show side from that sentence so it only describes hiding. Up to you!
| // to fill the gap so no empty space is visible to the user. | ||
|
|
||
| // Header gap compensation — only when content is NOT fullscreen. | ||
| // When fullscreen=true, inner-scroll already fills the full viewport |
There was a problem hiding this comment.
Nit: this reads inverted to me. offset-top = 0 is the non-fullscreen case, not fullscreen. In fullscreen --offset-top is the header height. The gate itself is fine, just the reasoning in the comment. No worries if you'd rather leave it.
| z-index: 0; | ||
| } | ||
|
|
||
| .header-collapse-condense ion-toolbar:last-of-type { |
There was a problem hiding this comment.
Nit: I think this is a leftover from the ios -> common move? .header-collapse-condense ion-toolbar:last-of-type { --border-width: 0px } now lives in header.common.scss too, which the ios bundle pulls in via header.native, so this copy looks redundant. Feel free to drop it if so.
There was a problem hiding this comment.
This was intentional, in the ios theme specifically the common style is overwritten by the .header-ios ion-toolbar:last-of-type selector so we need to add it back here.
|
|
||
| if (isScrollingDown !== wasScrollingDown) { | ||
| this.scrollDirectionChangeTop = this.lastScrollTop; | ||
| private destroyScrollEffect() { |
There was a problem hiding this comment.
Nit: the mechanism converged really nicely across the three, but the method names still differ a bit, tab-bar has setupScrollEffect/destroyScrollEffect where header/footer have setupScrollEffectHide/destroyCollapsible*. Might be worth aligning them (or a quick comment on why tab-bar's differ) so they read the same at a glance. Totally optional.
There was a problem hiding this comment.
I added a comment around this but I kept them named differently because the tab bar only supports hide, whereas the header and footer have multiple scroll effects (collapse, fade, hide) fa7d2d0
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
Deprecated
collapsepropscrollEffectpropFooter
Deprecated
collapsepropscrollEffectpropTab Bar