From 3e1e3e281698487510895e50ff3136542e8dbe31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Rio?= Date: Thu, 28 May 2026 17:17:02 +0100 Subject: [PATCH 1/3] feat(toolbar): add hideOnScroll for header and footer toolbars --- core/api.txt | 1 + core/src/components.d.ts | 15 +- core/src/components/content/content.scss | 87 +++- core/src/components/footer/footer.scss | 23 +- core/src/components/header/header.common.scss | 23 + core/src/components/header/header.tsx | 13 + .../toolbar/test/hide-on-scroll/index.html | 169 ++++++ .../components/toolbar/toolbar.common.scss | 25 + core/src/components/toolbar/toolbar.tsx | 490 +++++++++++++++++- packages/angular/src/directives/proxies.ts | 4 +- .../standalone/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 3 +- 12 files changed, 840 insertions(+), 17 deletions(-) create mode 100644 core/src/components/toolbar/test/hide-on-scroll/index.html diff --git a/core/api.txt b/core/api.txt index dafa0080ccf..a2150451642 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2785,6 +2785,7 @@ ion-toggle,part,track ion-toolbar,shadow ion-toolbar,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true +ion-toolbar,prop,hideOnScroll,boolean,false,false,false ion-toolbar,prop,mode,"ios" | "md",undefined,false,false ion-toolbar,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-toolbar,prop,titlePlacement,"center" | "end" | "start" | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index c47221f63cb..7472d66e98f 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1517,7 +1517,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) + * 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) When any child `ion-toolbar` has `hideOnScroll` set to `true`, the collapse behavior is skipped and `hideOnScroll` takes precedence. */ "collapse"?: 'condense' | 'fade'; /** @@ -4505,6 +4505,11 @@ export namespace Components { * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). */ "color"?: Color; + /** + * If `true`, the toolbar will be hidden when the user scrolls down and shown when the user scrolls up. Applies when the toolbar is inside an `ion-header` or `ion-footer` and a sibling `ion-content` exists on the same page. The header slides up and fades; the footer slides down. `ion-content` scroll insets are coordinated per edge so top and bottom toolbars can hide independently. When the theme is `"ios"`, this takes precedence over `ion-header[collapse="condense" | "fade"]` if both are set on the same header. + * @default false + */ + "hideOnScroll": boolean; /** * The mode determines the platform behaviors of the component. */ @@ -7565,7 +7570,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) + * 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) When any child `ion-toolbar` has `hideOnScroll` set to `true`, the collapse behavior is skipped and `hideOnScroll` takes precedence. */ "collapse"?: 'condense' | 'fade'; /** @@ -10691,6 +10696,11 @@ declare namespace LocalJSX { * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). */ "color"?: Color; + /** + * If `true`, the toolbar will be hidden when the user scrolls down and shown when the user scrolls up. Applies when the toolbar is inside an `ion-header` or `ion-footer` and a sibling `ion-content` exists on the same page. The header slides up and fades; the footer slides down. `ion-content` scroll insets are coordinated per edge so top and bottom toolbars can hide independently. When the theme is `"ios"`, this takes precedence over `ion-header[collapse="condense" | "fade"]` if both are set on the same header. + * @default false + */ + "hideOnScroll"?: boolean; /** * The mode determines the platform behaviors of the component. */ @@ -11506,6 +11516,7 @@ declare namespace LocalJSX { interface IonToolbarAttributes { "color": Color; "titlePlacement": 'start' | 'center' | 'end'; + "hideOnScroll": boolean; } interface IntrinsicElements { diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index 4024560ff41..db4d9364b99 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -1,4 +1,4 @@ -@import "../../themes/native/native.globals"; +@use "../../themes/native/native.globals" as globals; // Content // -------------------------------------------------- @@ -19,8 +19,8 @@ * @prop --offset-top: Offset top of the content * @prop --offset-bottom: Offset bottom of the content */ - --background: #{$background-color}; - --color: #{$text-color}; + --background: #{globals.$background-color}; + --color: #{globals.$text-color}; --padding-top: 0px; --padding-bottom: 0px; --padding-start: 0px; @@ -44,7 +44,7 @@ padding: 0 !important; /* stylelint-enable */ - font-family: $font-family-base; + font-family: globals.$font-family-base; contain: size style; } @@ -55,7 +55,7 @@ } #background-content { - @include position(calc(var(--offset-top) * -1), 0px, calc(var(--offset-bottom) * -1), 0px); + @include globals.position(calc(var(--offset-top) * -1), 0px, calc(var(--offset-bottom) * -1), 0px); position: absolute; @@ -63,8 +63,8 @@ } .inner-scroll { - @include position(calc(var(--offset-top) * -1), 0px, calc(var(--offset-bottom) * -1), 0px); - @include padding( + @include globals.position(calc(var(--offset-top) * -1), 0px, calc(var(--offset-bottom) * -1), 0px); + @include globals.padding( calc(var(--padding-top) + var(--offset-top)), var(--padding-end), calc( @@ -145,6 +145,79 @@ top: -1px; } +// Toolbar hide on scroll — ion-content coordination +// -------------------------------------------------- +// Applied when `ion-toolbar` sets `hideOnScroll` on a page with sibling +// `ion-content`. Header/footer overlay the viewport; scroll insets use +// `--toolbar-hide-offset-*` (measured by ion-toolbar) and animatable +// `--toolbar-hide-inset-*` so padding eases without layout jumps. Top and +// bottom edges are independent (separate classes and CSS variables). + +@property --toolbar-hide-inset-top { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --toolbar-hide-inset-bottom { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +:host(.content-toolbar-hide-offset-top), +:host(.content-toolbar-hide-offset-bottom) { + #background-content, + .inner-scroll { + @include globals.position(0, 0, 0, 0); + } +} + +// Top inset (header toolbar) +:host(.content-toolbar-hide-offset-top) { + --toolbar-hide-inset-top: var(--toolbar-hide-offset-top, 0px); + + transition: --toolbar-hide-inset-top var(--token-transition-time-300, 300ms) + var(--token-transition-curve-quick, cubic-bezier(0, 0, 0.2, 1)); +} + +:host(.content-toolbar-hide-offset-top.content-header-scroll-hidden) { + --toolbar-hide-inset-top: 0px; + + transition: --toolbar-hide-inset-top var(--token-transition-time-200, 200ms) + var(--token-transition-curve-base, cubic-bezier(0.4, 0, 1, 1)); +} + +:host(.content-toolbar-hide-offset-top) .inner-scroll { + padding-top: calc(var(--padding-top) + var(--toolbar-hide-inset-top, 0px)); +} + +:host(.content-toolbar-hide-offset-top) #background-content { + padding-top: var(--toolbar-hide-inset-top, 0px); +} + +// Bottom inset (footer toolbar) +:host(.content-toolbar-hide-offset-bottom) { + --toolbar-hide-inset-bottom: var(--toolbar-hide-offset-bottom, 0px); + + transition: --toolbar-hide-inset-bottom var(--token-transition-time-200, 200ms) + var(--token-transition-curve-spring, cubic-bezier(0.16, 1, 0.3, 1)); +} + +:host(.content-toolbar-hide-offset-bottom.content-footer-scroll-hidden) { + --toolbar-hide-inset-bottom: 0px; + + transition: --toolbar-hide-inset-bottom var(--token-transition-time-350, 350ms) + var(--token-transition-curve-base, cubic-bezier(0.4, 0, 1, 1)); +} + +:host(.content-toolbar-hide-offset-bottom) .inner-scroll { + padding-bottom: calc( + var(--padding-bottom) + var(--keyboard-offset) + var(--toolbar-hide-inset-bottom, 0px) + + var(--internal-content-safe-area-padding-bottom, 0px) + ); +} + :host(.content-sizing) { display: flex; diff --git a/core/src/components/footer/footer.scss b/core/src/components/footer/footer.scss index 90aa68f993e..6722b5265cb 100644 --- a/core/src/components/footer/footer.scss +++ b/core/src/components/footer/footer.scss @@ -1,4 +1,4 @@ -@import "../../themes/native/native.globals"; +@use "../../themes/native/native.globals" as globals; // Footer // -------------------------------------------------- @@ -11,9 +11,28 @@ ion-footer { width: 100%; - z-index: $z-index-toolbar; + z-index: globals.$z-index-toolbar; } ion-footer.footer-toolbar-padding ion-toolbar:last-of-type { padding-bottom: var(--ion-safe-area-bottom, 0); } + +// Footer Hide on Scroll +// -------------------------------------------------- +// Added by `ion-toolbar` when `hideOnScroll` is enabled. Overlays `ion-content`. + +ion-footer.ion-footer-hide-on-scroll { + @include globals.position(null, 0, 0, 0); + position: absolute; + + transition: transform var(--token-transition-time-200, 200ms) + var(--token-transition-curve-spring, cubic-bezier(0.16, 1, 0.3, 1)); +} + +ion-footer.ion-footer-hide-on-scroll.footer-scroll-hidden { + transform: translateY(calc(100% + var(--ion-safe-area-bottom, 0))); + + transition: transform var(--token-transition-time-350, 350ms) + var(--token-transition-curve-base, cubic-bezier(0.4, 0, 1, 1)); +} diff --git a/core/src/components/header/header.common.scss b/core/src/components/header/header.common.scss index e3364237921..28c3ce5d93f 100644 --- a/core/src/components/header/header.common.scss +++ b/core/src/components/header/header.common.scss @@ -1,3 +1,5 @@ +@use "../../themes/native/native.globals" as globals; + // Header // -------------------------------------------------- @@ -13,3 +15,24 @@ ion-header { ion-header ion-toolbar:first-of-type { padding-top: var(--ion-safe-area-top, 0); } + +// Header Hide on Scroll +// -------------------------------------------------- +// Added by `ion-toolbar` when `hideOnScroll` is enabled. Overlays `ion-content`. + +ion-header.ion-header-hide-on-scroll { + @include globals.position(0, 0, null, 0); + position: absolute; + + transition: transform var(--token-transition-time-300, 300ms) + var(--token-transition-curve-quick, cubic-bezier(0, 0, 0.2, 1)); + + z-index: 10; +} + +ion-header.ion-header-hide-on-scroll.header-scroll-hidden { + transform: translateY(calc(-100% - var(--ion-safe-area-top, 0))); + + transition: transform var(--token-transition-time-200, 200ms) + var(--token-transition-curve-base, cubic-bezier(0.4, 0, 1, 1)); +} diff --git a/core/src/components/header/header.tsx b/core/src/components/header/header.tsx index e922a388605..3a81bf9610c 100644 --- a/core/src/components/header/header.tsx +++ b/core/src/components/header/header.tsx @@ -45,6 +45,9 @@ export class Header implements ComponentInterface { * Only applies when the theme is `"ios"`. * * Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles) + * + * When any child `ion-toolbar` has `hideOnScroll` set to `true`, the collapse + * behavior is skipped and `hideOnScroll` takes precedence. */ @Prop() collapse?: 'condense' | 'fade'; @@ -80,6 +83,10 @@ export class Header implements ComponentInterface { this.destroyCollapsibleHeader(); } + private hasHideOnScrollToolbar(): boolean { + return Array.from(this.el.querySelectorAll('ion-toolbar')).some((toolbar) => toolbar.hideOnScroll); + } + private async checkCollapsibleHeader() { const theme = getIonTheme(this); @@ -87,6 +94,12 @@ export class Header implements ComponentInterface { return; } + // Destroy the collapsible header when `hideOnScroll` is present. + if (this.hasHideOnScrollToolbar()) { + this.destroyCollapsibleHeader(); + return; + } + const { collapse } = this; const hasCondense = collapse === 'condense'; const hasFade = collapse === 'fade'; diff --git a/core/src/components/toolbar/test/hide-on-scroll/index.html b/core/src/components/toolbar/test/hide-on-scroll/index.html new file mode 100644 index 00000000000..09639fb4ebb --- /dev/null +++ b/core/src/components/toolbar/test/hide-on-scroll/index.html @@ -0,0 +1,169 @@ + + + + + Toolbar - Hide on Scroll + + + + + + + + + + + + + + +
+ +
+
+

Scroll down to hide the toolbars, scroll up to show them.

+

Requirements:

+
    +
  • hide-on-scroll must be set on ion-toolbar
  • +
  • Toolbar must be inside an ion-header or ion-footer
  • +
  • A sibling ion-content must exist for scroll detection
  • +
+
+
+ Header toolbar + Footer toolbar +

Header and footer toolbars

+
+
+
+
+
+ + + + + + diff --git a/core/src/components/toolbar/toolbar.common.scss b/core/src/components/toolbar/toolbar.common.scss index bd9fd63185c..294cc8d6106 100644 --- a/core/src/components/toolbar/toolbar.common.scss +++ b/core/src/components/toolbar/toolbar.common.scss @@ -97,3 +97,28 @@ position: absolute; } + +// Toolbar: Hide on Scroll +// -------------------------------------------------- +// Variant A: top (default, inside ion-header) +// +// Visible (default + scroll up): single 300ms transition with curve.quick. +// Hidden (scroll down): 200ms slide + 300ms fade, both with curve.base. + +:host(.toolbar-hide-on-scroll) { + transition: transform var(--token-transition-time-300, 300ms) + var(--token-transition-curve-quick, cubic-bezier(0, 0, 0.2, 1)), + opacity var(--token-transition-time-300, 300ms) var(--token-transition-curve-quick, cubic-bezier(0, 0, 0.2, 1)); +} + +:host(.toolbar-hide-on-scroll.toolbar-scroll-hidden) { + transition: opacity var(--token-transition-time-300, 300ms) + var(--token-transition-curve-base, cubic-bezier(0.4, 0, 1, 1)); + + opacity: 0; +} + +// Variant B: bottom (inside ion-footer) +// +// The parent ion-footer handles the slide transform. Nothing to do here. +// -------------------------------------------------- diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index 7b885203078..ca72bf174d1 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -1,5 +1,18 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, forceUpdate, h, Host, Listen, Prop, Watch } from '@stencil/core'; +import { + Component, + Element, + forceUpdate, + h, + Host, + Listen, + Prop, + readTask, + State, + Watch, + writeTask, +} from '@stencil/core'; +import { findIonContent, getScrollElement } from '@utils/content'; import { createColorClasses, hostContext } from '@utils/theme'; import { getIonTheme } from '../../global/ionic-global'; @@ -29,7 +42,52 @@ import type { Color, CssClassMap, StyleEventDetail } from '../../interface'; shadow: true, }) export class Toolbar implements ComponentInterface { + /** Minimum cumulative downward scroll (px) before hiding the toolbar. */ + private static readonly HIDE_ON_SCROLL_THRESHOLD = 24; + + /** Debounce interval for container `ResizeObserver` callbacks. */ + private static readonly CONTAINER_RESIZE_DEBOUNCE_MS = 100; + + /** + * Refcount attribute on `ion-header` / `ion-footer` for `*-scroll-hidden` classes. + * Store how many hideOnScroll toolbars in the same header/footer are currently hidden. + */ + private static readonly TOOLBAR_HIDE_COUNT_ATTR = 'data-toolbar-hide-count'; + + /** Refcount attributes on `ion-content` for offset coordination classes. */ + private static readonly TOOLBAR_HIDE_OFFSET_TOP_COUNT_ATTR = 'data-toolbar-hide-offset-top-count'; + private static readonly TOOLBAR_HIDE_OFFSET_BOTTOM_COUNT_ATTR = 'data-toolbar-hide-offset-bottom-count'; + + /** + * Scroll elements being adjusted programmatically. + * Ignored by scroll handlers to prevent hide/show feedback loops. + */ + private static readonly programmaticScrollTargets = new WeakSet(); + + /** + * Shared scroll position per scroll element. Used when multiple toolbars on the + * same page listen to one `ion-content` scroll target. + */ + private static readonly scrollLastTopByElement = new WeakMap(); + + /** + * One passive scroll listener per scroll element; fans out to every toolbar + * with `hideOnScroll` on the same page. + */ + private static readonly scrollListeners = new WeakMap< + HTMLElement, + { callback: () => void; toolbars: Set } + >(); + private childrenStyles = new Map(); + private scrollEl?: HTMLElement; + private contentEl?: HTMLElement; + private cumulativeDownDelta = 0; + private containerEl?: HTMLElement; + private position: 'top' | 'bottom' = 'top'; + private hideOnScrollActive = false; + private containerResizeObserver?: ResizeObserver; + private containerResizeDebounceId?: number; private readonly slotClasses = [ 'has-start-content', 'has-end-content', @@ -58,6 +116,30 @@ export class Toolbar implements ComponentInterface { */ @Prop() titlePlacement?: 'start' | 'center' | 'end'; + /** + * If `true`, the toolbar will be hidden when the user scrolls down + * and shown when the user scrolls up. + * + * Applies when the toolbar is inside an `ion-header` or `ion-footer` + * and a sibling `ion-content` exists on the same page. The header + * slides up and fades; the footer slides down. + * `ion-content` scroll insets are coordinated per edge so top and bottom toolbars can hide + * independently. + * + * When the theme is `"ios"`, this takes precedence over + * `ion-header[collapse="condense" | "fade"]` if both are set on the + * same header. + */ + @Prop() hideOnScroll = false; + + @State() scrollHidden = false; + + @Watch('hideOnScroll') + hideOnScrollChanged() { + this.destroyHideOnScroll(); + void this.setupHideOnScroll(); + } + componentWillLoad() { const buttons = Array.from(this.el.querySelectorAll('ion-buttons')); @@ -83,6 +165,11 @@ export class Toolbar implements ComponentInterface { componentDidLoad() { this.updateSlotClasses(); this.updateSlotWidths(); + void this.setupHideOnScroll(); + } + + disconnectedCallback() { + this.destroyHideOnScroll(); } @Watch('titlePlacement') @@ -266,6 +353,403 @@ export class Toolbar implements ComponentInterface { return !!slotNode && slotNode.assignedNodes().length > 0; } + // --------------------------------------------------------------------------- + // Hide on scroll + // --------------------------------------------------------------------------- + + /** + * Locates the page `ion-content` used for scroll detection and inset coordination. + */ + private findPageContent(): HTMLElement | null { + if (!this.containerEl) { + return null; + } + + const pageEl = this.containerEl.closest('ion-page, .ion-page'); + if (pageEl) { + return findIonContent(pageEl); + } + + const parent = this.containerEl.parentElement; + if (parent) { + return findIonContent(parent); + } + + return null; + } + + /** + * Initializes scroll listening and coordinates `ion-content` insets when + * `hideOnScroll` is enabled inside `ion-header` or `ion-footer`. + */ + private async setupHideOnScroll() { + if (!this.hideOnScroll) { + return; + } + + const headerEl = this.el.closest('ion-header'); + const footerEl = this.el.closest('ion-footer'); + + if (headerEl) { + this.containerEl = headerEl; + this.position = 'top'; + } else if (footerEl) { + this.containerEl = footerEl; + this.position = 'bottom'; + } else { + return; + } + + const contentEl = this.findPageContent(); + + if (!contentEl) { + return; + } + + this.contentEl = contentEl; + this.hideOnScrollActive = true; + + const containerClass = this.position === 'top' ? 'ion-header-hide-on-scroll' : 'ion-footer-hide-on-scroll'; + this.containerEl.classList.add(containerClass); + + this.enableContentOffsetCoordination(true); + writeTask(() => this.updateContentOffsetSize()); + this.observeContainerResize(); + + await this.initScrollListener(contentEl); + } + + /** + * Keeps `--toolbar-hide-offset-*` in sync when the header/footer height changes. + */ + private observeContainerResize() { + if (typeof ResizeObserver === 'undefined' || !this.containerEl) { + return; + } + + this.containerResizeObserver = new ResizeObserver(() => { + if (this.containerResizeDebounceId !== undefined) { + clearTimeout(this.containerResizeDebounceId); + } + + this.containerResizeDebounceId = window.setTimeout(() => { + this.containerResizeDebounceId = undefined; + writeTask(() => this.updateContentOffsetSize()); + }, Toolbar.CONTAINER_RESIZE_DEBOUNCE_MS); + }); + this.containerResizeObserver.observe(this.containerEl); + } + + /** Get last known `scrollTop` for `scrollEl`. */ + private static getScrollLastTop(scrollEl: HTMLElement) { + let lastScrollTop = Toolbar.scrollLastTopByElement.get(scrollEl); + if (lastScrollTop === undefined) { + lastScrollTop = scrollEl.scrollTop; + Toolbar.scrollLastTopByElement.set(scrollEl, lastScrollTop); + } + return lastScrollTop; + } + + /** Update `scrollTop` after each scroll event for the given scroll element. */ + private static setScrollLastTop(scrollEl: HTMLElement, scrollTop: number) { + Toolbar.scrollLastTopByElement.set(scrollEl, scrollTop); + } + + /** Get element with the scrollable content inside `ion-content` and register it to the scroll listener. */ + private async initScrollListener(contentEl: HTMLElement) { + const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); + this.registerScrollListener(scrollEl); + } + + /** + * Register a scroll listener per scroll element and notify each toolbar that's active. + */ + private registerScrollListener(scrollEl: HTMLElement) { + let entry = Toolbar.scrollListeners.get(scrollEl); + + if (!entry) { + Toolbar.setScrollLastTop(scrollEl, scrollEl.scrollTop); + + const callback = () => { + readTask(() => { + const scrollTop = scrollEl.scrollTop; + + if (Toolbar.programmaticScrollTargets.has(scrollEl)) { + Toolbar.setScrollLastTop(scrollEl, scrollTop); + return; + } + + const lastScrollTop = Toolbar.getScrollLastTop(scrollEl); + const deltaY = scrollTop - lastScrollTop; + Toolbar.setScrollLastTop(scrollEl, scrollTop); + + Toolbar.scrollListeners.get(scrollEl)?.toolbars.forEach((toolbar) => { + toolbar.handleHideOnScrollDelta(deltaY); + }); + }); + }; + + scrollEl.addEventListener('scroll', callback, { passive: true }); + entry = { callback, toolbars: new Set() }; + Toolbar.scrollListeners.set(scrollEl, entry); + } + + entry.toolbars.add(this); + } + + /** Unset the scroll listener for the given scroll element. */ + private unregisterScrollListener() { + if (!this.scrollEl) { + return; + } + + const entry = Toolbar.scrollListeners.get(this.scrollEl); + if (!entry) { + return; + } + + entry.toolbars.delete(this); + + if (entry.toolbars.size === 0) { + this.scrollEl.removeEventListener('scroll', entry.callback); + Toolbar.scrollListeners.delete(this.scrollEl); + Toolbar.scrollLastTopByElement.delete(this.scrollEl); + } + } + + /** + * Applies scroll delta for this toolbar (called from the shared listener). + */ + private handleHideOnScrollDelta(deltaY: number) { + if (!this.hideOnScrollActive) { + return; + } + + const shouldHide = this.checkScrollStatus(deltaY); + + if (shouldHide !== this.scrollHidden) { + writeTask(() => { + this.setScrollHidden(shouldHide); + }); + } + } + + /** + * Remove scroll listeners, classes, and update state. + */ + private destroyHideOnScroll() { + this.unregisterScrollListener(); + this.scrollEl = undefined; + + if (this.scrollHidden) { + this.setScrollHidden(false); + } else { + this.scrollHidden = false; + } + + if (this.hideOnScrollActive && this.containerEl) { + const containerClass = this.position === 'top' ? 'ion-header-hide-on-scroll' : 'ion-footer-hide-on-scroll'; + this.containerEl.classList.remove(containerClass); + this.containerEl.removeAttribute(Toolbar.TOOLBAR_HIDE_COUNT_ATTR); + } + + this.enableContentOffsetCoordination(false); + + if (this.containerResizeObserver) { + this.containerResizeObserver.disconnect(); + this.containerResizeObserver = undefined; + } + + if (this.containerResizeDebounceId !== undefined) { + clearTimeout(this.containerResizeDebounceId); + this.containerResizeDebounceId = undefined; + } + + this.contentEl = undefined; + this.containerEl = undefined; + this.hideOnScrollActive = false; + this.cumulativeDownDelta = 0; + } + + /** + * Update hidden state, scroll insets, and container/content classes. + */ + private setScrollHidden(hidden: boolean) { + if (hidden === this.scrollHidden) { + return; + } + + this.compensateScrollForInsetChange(hidden); + this.scrollHidden = hidden; + this.updateContainerAndContentClasses(hidden); + } + + /** + * Adjust scroll position when the inset animates so visible content does not jump. + */ + private compensateScrollForInsetChange(hidden: boolean) { + if (!this.scrollEl || !this.containerEl) { + return; + } + + const insetDelta = this.containerEl.offsetHeight; + if (insetDelta <= 0) { + return; + } + + const scrollEl = this.scrollEl; + Toolbar.programmaticScrollTargets.add(scrollEl); + + if (this.position === 'top') { + if (hidden) { + scrollEl.scrollTop = Math.max(0, scrollEl.scrollTop - insetDelta); + } else { + scrollEl.scrollTop += insetDelta; + } + Toolbar.setScrollLastTop(scrollEl, scrollEl.scrollTop); + } else if (hidden) { + const maxScrollTop = scrollEl.scrollHeight - scrollEl.clientHeight; + scrollEl.scrollTop = Math.min(scrollEl.scrollTop, Math.max(0, maxScrollTop)); + Toolbar.setScrollLastTop(scrollEl, scrollEl.scrollTop); + } + + requestAnimationFrame(() => { + Toolbar.programmaticScrollTargets.delete(scrollEl); + }); + } + + /** + * Turns `ion-content` scroll inset coordination on or off for top/bottom. + * + * The header/footer overlays the viewport, so content needs extra padding. + * Top and bottom are counted separately so a page can have both header and footer + * `hideOnScroll` toolbars without one disabling the other. + */ + private enableContentOffsetCoordination(enable: boolean) { + if (!this.contentEl || !this.containerEl) { + return; + } + + const isTop = this.position === 'top'; + // How many hide-on-scroll toolbars on this top/bottom + const countAttr = isTop + ? Toolbar.TOOLBAR_HIDE_OFFSET_TOP_COUNT_ATTR + : Toolbar.TOOLBAR_HIDE_OFFSET_BOTTOM_COUNT_ATTR; + // CSS class for top/bottom ion-content + const contentClass = isTop ? 'content-toolbar-hide-offset-top' : 'content-toolbar-hide-offset-bottom'; + // CSS variable for height from header/footer + const offsetVar = isTop ? '--toolbar-hide-offset-top' : '--toolbar-hide-offset-bottom'; + // CSS class for scroll-hidden state on ion-content + const hiddenClass = isTop ? 'content-header-scroll-hidden' : 'content-footer-scroll-hidden'; + + // How many hide-on-scroll toolbars on this top/bottom + const prevCount = parseInt(this.contentEl.getAttribute(countAttr) || '0', 10); + // Increment/decrement the count + const newCount = enable ? prevCount + 1 : Math.max(0, prevCount - 1); + this.contentEl.setAttribute(countAttr, String(newCount)); + + // First toolbar on this top/bottom + if (enable && prevCount === 0) { + this.contentEl.classList.add(contentClass); + this.updateContentOffsetSize(); + } else if (!enable && newCount === 0) { + // Last toolbar on this top/bottom + this.contentEl.classList.remove(contentClass, hiddenClass); + this.contentEl.style.removeProperty(offsetVar); + this.contentEl.removeAttribute(countAttr); + } + } + + /** + * Writes measured header/footer height to CSS variables on `ion-content`. + */ + private updateContentOffsetSize() { + if (!this.contentEl || !this.containerEl) { + return; + } + + const heightPx = this.containerEl.offsetHeight; + if (heightPx <= 0) { + return; + } + + const offsetVar = this.position === 'top' ? '--toolbar-hide-offset-top' : '--toolbar-hide-offset-bottom'; + const nextValue = `${heightPx}px`; + const currentValue = this.contentEl.style.getPropertyValue(offsetVar); + + if (currentValue === nextValue) { + return; + } + + this.contentEl.style.setProperty(offsetVar, nextValue); + } + + /** + * Applies scroll-hidden UI for this toolbar's edge (header or footer). + * + * Uses `data-toolbar-hide-count` on the container so multiple `hideOnScroll` + * toolbars in the same header/footer only toggle classes when the last one + * hides or when the first one shows. + */ + private updateContainerAndContentClasses(hidden: boolean) { + if (!this.hideOnScrollActive || !this.containerEl) { + return; + } + + // Slide the header/footer off-screen + const hiddenClass = this.position === 'top' ? 'header-scroll-hidden' : 'footer-scroll-hidden'; + // Collapse `ion-content` while hidden + const contentClass = this.position === 'top' ? 'content-header-scroll-hidden' : 'content-footer-scroll-hidden'; + const countAttr = Toolbar.TOOLBAR_HIDE_COUNT_ATTR; + const prevCount = parseInt(this.containerEl.getAttribute(countAttr) || '0', 10); + + if (hidden) { + const newCount = prevCount + 1; + this.containerEl.setAttribute(countAttr, String(newCount)); + + // First hidden toolbar on this container: enable hidden styles. + if (prevCount === 0) { + this.containerEl.classList.add(hiddenClass); + if (this.contentEl && !this.contentEl.classList.contains(contentClass)) { + this.contentEl.classList.add(contentClass); + } + } + } else { + const newCount = Math.max(0, prevCount - 1); + this.containerEl.setAttribute(countAttr, String(newCount)); + + // Last visible toolbar on this container: restore header/footer and inset. + if (newCount === 0) { + this.containerEl.classList.remove(hiddenClass); + if (this.contentEl?.classList.contains(contentClass)) { + this.contentEl.classList.remove(contentClass); + } + // Re-measure so `--toolbar-hide-offset-*` matches the shown toolbar height. + this.updateContentOffsetSize(); + } + } + } + + /** + * Returns whether the toolbar should be hidden for the current scroll delta. + */ + private checkScrollStatus(deltaY: number): boolean { + if (deltaY < 0) { + this.cumulativeDownDelta = 0; + return false; + } + + if (deltaY > 0) { + this.cumulativeDownDelta += deltaY; + + if (this.cumulativeDownDelta >= Toolbar.HIDE_ON_SCROLL_THRESHOLD) { + return true; + } + } + + return this.scrollHidden; + } + @Listen('ionStyle') childrenStyle(ev: CustomEvent) { ev.stopPropagation(); @@ -301,6 +785,7 @@ export class Toolbar implements ComponentInterface { }); const titlePlacement = this.getTitlePlacement(); + const { hideOnScroll, scrollHidden, position } = this; return ( diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index c1dfbec20a0..86f80460b10 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2658,14 +2658,14 @@ This event will not emit when programmatically setting the `checked` property. @ProxyCmp({ - inputs: ['color', 'mode', 'theme', 'titlePlacement'] + inputs: ['color', 'hideOnScroll', 'mode', 'theme', 'titlePlacement'] }) @Component({ selector: 'ion-toolbar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'mode', 'theme', 'titlePlacement'], + inputs: ['color', 'hideOnScroll', 'mode', 'theme', 'titlePlacement'], }) export class IonToolbar { protected el: HTMLIonToolbarElement; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index ef1a118f4f7..28ba10fb933 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -2323,14 +2323,14 @@ Shorthand for ionToastDidDismiss. @ProxyCmp({ defineCustomElementFn: defineIonToolbar, - inputs: ['color', 'mode', 'theme', 'titlePlacement'] + inputs: ['color', 'hideOnScroll', 'mode', 'theme', 'titlePlacement'] }) @Component({ selector: 'ion-toolbar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'mode', 'theme', 'titlePlacement'], + inputs: ['color', 'hideOnScroll', 'mode', 'theme', 'titlePlacement'], standalone: true }) export class IonToolbar { diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 116c608e061..7bf988245cf 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -1135,6 +1135,7 @@ export const IonToggle: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-toolbar', defineIonToolbar, [ 'color', - 'titlePlacement' + 'titlePlacement', + 'hideOnScroll' ]); From d62e8c50700c2b6c2b1127260c1e69d9f5a86c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Rio?= Date: Thu, 28 May 2026 17:44:28 +0100 Subject: [PATCH 2/3] force pr update --- core/src/components/toolbar/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index ca72bf174d1..a3e73e2a334 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -118,7 +118,7 @@ export class Toolbar implements ComponentInterface { /** * If `true`, the toolbar will be hidden when the user scrolls down - * and shown when the user scrolls up. + * and shown when the user scrolls up * * Applies when the toolbar is inside an `ion-header` or `ion-footer` * and a sibling `ion-content` exists on the same page. The header From 6c02eaf2cd1962803f2072b38c6116301e7b7c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Rio?= Date: Thu, 28 May 2026 17:51:34 +0100 Subject: [PATCH 3/3] revert change --- core/src/components/toolbar/toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/components/toolbar/toolbar.tsx b/core/src/components/toolbar/toolbar.tsx index a3e73e2a334..ca72bf174d1 100644 --- a/core/src/components/toolbar/toolbar.tsx +++ b/core/src/components/toolbar/toolbar.tsx @@ -118,7 +118,7 @@ export class Toolbar implements ComponentInterface { /** * If `true`, the toolbar will be hidden when the user scrolls down - * and shown when the user scrolls up + * and shown when the user scrolls up. * * Applies when the toolbar is inside an `ion-header` or `ion-footer` * and a sibling `ion-content` exists on the same page. The header