@@ -4,8 +4,10 @@ import { ION_PAGE_ELEMENT_SELECTOR, findIonContent, getScrollElement, printIonCo
44import type { KeyboardController } from '@utils/keyboard/keyboard-controller' ;
55import { createKeyboardController } from '@utils/keyboard/keyboard-controller' ;
66import { printIonWarning } from '@utils/logging' ;
7+ import type { ScrollHideController } from '@utils/scroll-hide-controller' ;
8+ import { createScrollHideController } from '@utils/scroll-hide-controller' ;
79
8- import { getIonMode , getIonTheme } from '../../global/ionic-global' ;
10+ import { getIonTheme } from '../../global/ionic-global' ;
911
1012import type { FooterScrollEffect } from './footer-interface' ;
1113import { handleFooterFade } from './footer.utils' ;
@@ -25,22 +27,12 @@ import { handleFooterFade } from './footer.utils';
2527export class Footer implements ComponentInterface {
2628 private scrollEl ?: HTMLElement ;
2729 private contentScrollCallback ?: ( ) => void ;
28- private contentWheelCallback ?: EventListener ;
2930 private keyboardCtrl : KeyboardController | null = null ;
3031 private keyboardCtrlPromise : Promise < KeyboardController > | null = null ;
32+ private scrollHideCtrl ?: ScrollHideController ;
3133 private resizeObserver ?: ResizeObserver ;
3234 private contentEl ?: HTMLElement ;
33-
34- // scrollEffect="hide" scroll tracking state
35- private scrollHidden = false ;
36- private previousScrollTop = 0 ;
37- private scrollTopAtDirectionChange = 0 ;
38- private lastWheelEventTime = 0 ;
39- private suppressShowUntil = 0 ;
40-
41- private readonly TOP_VISIBLE_THRESHOLD = 80 ;
42- private readonly SCROLL_HIDE_THRESHOLD = 60 ;
43- private readonly WHEEL_SUPPRESS_DURATION_MS = 80 ;
35+ private isHidden = false ;
4436
4537 @State ( ) private keyboardVisible = false ;
4638
@@ -56,7 +48,7 @@ export class Footer implements ComponentInterface {
5648
5749 /**
5850 * Describes the scroll effect that will be applied to the footer.
59- * Only applies when the mode is `"ios"`.
51+ * Only applies when the theme is `"ios"`.
6052 *
6153 * @deprecated Use `scrollEffect` instead.
6254 */
@@ -147,8 +139,8 @@ export class Footer implements ComponentInterface {
147139 }
148140
149141 // fade via the deprecated `collapse` prop is iOS-only.
150- // fade via the new `scrollEffect` prop works in all modes .
151- const isModeRestricted = scrollEffect === undefined && getIonMode ( this ) !== 'ios' ;
142+ // fade via the new `scrollEffect` prop works in all themes .
143+ const isModeRestricted = scrollEffect === undefined && getIonTheme ( this ) !== 'ios' ;
152144 if ( hasFade && ! isModeRestricted ) {
153145 if ( ! contentEl ) {
154146 printIonContentErrorMsg ( this . el ) ;
@@ -161,123 +153,47 @@ export class Footer implements ComponentInterface {
161153
162154 private setupScrollEffectHide = async ( contentEl : HTMLElement ) => {
163155 this . contentEl = contentEl ;
164- const scrollEl = ( this . scrollEl = await getScrollElement ( contentEl ) ) ;
156+ const scrollEl = await getScrollElement ( contentEl ) ;
165157
166- this . updateHideSlideY ( ) ;
158+ this . updateHideHeight ( ) ;
167159
168160 if ( typeof ResizeObserver !== 'undefined' ) {
169- this . resizeObserver = new ResizeObserver ( ( ) => this . updateHideSlideY ( ) ) ;
161+ this . resizeObserver = new ResizeObserver ( ( ) => this . updateHideHeight ( ) ) ;
170162 this . resizeObserver . observe ( this . el ) ;
171163 }
172164
173- this . contentScrollCallback = ( ) => this . handleScrollEffectHide ( ) ;
174- scrollEl . addEventListener ( 'scroll' , this . contentScrollCallback , { passive : true } ) ;
175-
176- this . contentWheelCallback = ( ev : Event ) => this . handleWheelEffectHide ( ev as WheelEvent ) ;
177- scrollEl . addEventListener ( 'wheel' , this . contentWheelCallback , { passive : true } ) ;
165+ this . scrollHideCtrl = createScrollHideController ( scrollEl , ( hidden ) => this . setHidden ( hidden ) ) ;
178166
179167 contentEl . classList . add ( 'content-footer-hide-scroll-partner' ) ;
180168 } ;
181169
182- private updateHideSlideY ( ) {
170+ /**
171+ * Reads the footer's current height and writes it as a CSS variable
172+ * on both the footer and the sibling content. The content uses this
173+ * value to expand its scroll area when the footer hides (gap compensation).
174+ */
175+ private updateHideHeight ( ) {
183176 readTask ( ( ) => {
184177 const footerHeightPx = this . el . offsetHeight ;
185178 writeTask ( ( ) => {
186- this . el . style . setProperty ( '--internal-footer-hide-slide-y ' , `${ footerHeightPx } px` ) ;
179+ this . el . style . setProperty ( '--internal-footer-hide-height ' , `${ footerHeightPx } px` ) ;
187180 if ( this . contentEl ) {
188- this . contentEl . style . setProperty ( '--internal-footer-hide-slide-y ' , `${ footerHeightPx } px` ) ;
181+ this . contentEl . style . setProperty ( '--internal-footer-hide-height ' , `${ footerHeightPx } px` ) ;
189182 }
190183 } ) ;
191184 } ) ;
192185 }
193186
194- private handleWheelEffectHide = ( ev : WheelEvent ) => {
195- this . lastWheelEventTime = Date . now ( ) ;
196-
197- readTask ( ( ) => {
198- const currentScrollTop = this . scrollEl ! . scrollTop ;
199-
200- if ( currentScrollTop <= this . TOP_VISIBLE_THRESHOLD ) {
201- if ( this . scrollHidden ) {
202- writeTask ( ( ) => this . setHidden ( false ) ) ;
203- }
204- return ;
205- }
206-
207- if ( ev . deltaY < 0 ) {
208- this . scrollTopAtDirectionChange = currentScrollTop ;
209- if ( this . scrollHidden ) {
210- writeTask ( ( ) => this . setHidden ( false ) ) ;
211- }
212- } else if ( ev . deltaY > 0 ) {
213- const scrolledSinceDirectionChange = currentScrollTop - this . scrollTopAtDirectionChange ;
214- if ( scrolledSinceDirectionChange >= this . SCROLL_HIDE_THRESHOLD && ! this . scrollHidden ) {
215- writeTask ( ( ) => this . setHidden ( true ) ) ;
216- }
217- }
218- } ) ;
219- } ;
220-
221- private handleScrollEffectHide = ( ) => {
222- // Suppress scroll events shortly after a wheel event — delta already processed via wheel
223- if ( Date . now ( ) - this . lastWheelEventTime < this . WHEEL_SUPPRESS_DURATION_MS ) {
224- return ;
225- }
226-
227- readTask ( ( ) => {
228- const currentScrollTop = this . scrollEl ! . scrollTop ;
229-
230- if ( currentScrollTop <= this . TOP_VISIBLE_THRESHOLD ) {
231- if ( this . scrollHidden ) {
232- writeTask ( ( ) => this . setHidden ( false ) ) ;
233- }
234- this . previousScrollTop = currentScrollTop ;
235- return ;
236- }
237-
238- const isScrollingDown = currentScrollTop > this . previousScrollTop ;
239- const wasScrollingDown = this . previousScrollTop > this . scrollTopAtDirectionChange ;
240-
241- if ( isScrollingDown !== wasScrollingDown ) {
242- this . scrollTopAtDirectionChange = this . previousScrollTop ;
243- }
244-
245- const scrolledSinceDirectionChange = Math . abs ( currentScrollTop - this . scrollTopAtDirectionChange ) ;
246- const requiredScrollDistance = isScrollingDown ? this . SCROLL_HIDE_THRESHOLD : 0 ;
247- this . previousScrollTop = currentScrollTop ;
248-
249- if ( scrolledSinceDirectionChange < requiredScrollDistance ) {
250- return ;
251- }
252-
253- const shouldHide = isScrollingDown ;
254- if ( shouldHide !== this . scrollHidden ) {
255- // After hiding, the content height increases (CSS transition), which lowers
256- // max scrollTop and triggers a spurious upward-scroll event. Suppress "show"
257- // actions briefly to absorb that adjustment.
258- if ( ! shouldHide && Date . now ( ) < this . suppressShowUntil ) {
259- return ;
260- }
261- writeTask ( ( ) => this . setHidden ( shouldHide ) ) ;
262- }
263- } ) ;
264- } ;
265-
266187 private setHidden ( hidden : boolean ) {
267- this . scrollHidden = hidden ;
188+ this . isHidden = hidden ;
268189 this . el . classList . toggle ( 'footer-scroll-hidden' , hidden ) ;
269190
270191 if ( hidden ) {
271192 this . el . setAttribute ( 'inert' , '' ) ;
272193 this . el . setAttribute ( 'aria-hidden' , 'true' ) ;
273- // Suppress "show" events for slightly longer than the content height
274- // transition (350ms) to prevent the scrollTop adjustment from immediately
275- // re-showing the footer.
276- this . suppressShowUntil = Date . now ( ) + 400 ;
277194 } else {
278195 this . el . removeAttribute ( 'inert' ) ;
279196 this . el . removeAttribute ( 'aria-hidden' ) ;
280- this . suppressShowUntil = 0 ;
281197 }
282198
283199 if ( this . contentEl ) {
@@ -305,9 +221,9 @@ export class Footer implements ComponentInterface {
305221 this . contentScrollCallback = undefined ;
306222 }
307223
308- if ( this . scrollEl && this . contentWheelCallback ) {
309- this . scrollEl . removeEventListener ( 'wheel' , this . contentWheelCallback ) ;
310- this . contentWheelCallback = undefined ;
224+ if ( this . scrollHideCtrl ) {
225+ this . scrollHideCtrl . destroy ( ) ;
226+ this . scrollHideCtrl = undefined ;
311227 }
312228
313229 if ( this . resizeObserver ) {
@@ -317,21 +233,17 @@ export class Footer implements ComponentInterface {
317233
318234 if ( this . contentEl ) {
319235 this . contentEl . classList . remove ( 'content-footer-hide-scroll-partner' , 'content-footer-hide-scroll-hidden' ) ;
320- this . contentEl . style . removeProperty ( '--internal-footer-hide-slide-y ' ) ;
236+ this . contentEl . style . removeProperty ( '--internal-footer-hide-height ' ) ;
321237 this . contentEl = undefined ;
322238 }
323239
324- if ( this . scrollHidden ) {
240+ if ( this . isHidden ) {
325241 this . el . classList . remove ( 'footer-scroll-hidden' ) ;
326242 this . el . removeAttribute ( 'inert' ) ;
327243 this . el . removeAttribute ( 'aria-hidden' ) ;
328- this . scrollHidden = false ;
244+ this . isHidden = false ;
329245 }
330- this . el . style . removeProperty ( '--internal-footer-hide-slide-y' ) ;
331- this . previousScrollTop = 0 ;
332- this . scrollTopAtDirectionChange = 0 ;
333- this . lastWheelEventTime = 0 ;
334- this . suppressShowUntil = 0 ;
246+ this . el . style . removeProperty ( '--internal-footer-hide-height' ) ;
335247 }
336248
337249 render ( ) {
0 commit comments