From a81004579eb0237575360abe9495650730e1bdcc Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Thu, 30 Oct 2025 16:04:17 +0200 Subject: [PATCH] fix(ui5-dynamic-page): prevent unintentional scroll when focusing title/header --- .../fiori/cypress/specs/DynamicPage.cy.tsx | 52 +++++++++++++ packages/fiori/src/DynamicPage.ts | 78 ++++++++----------- packages/fiori/src/DynamicPageTemplate.tsx | 7 +- 3 files changed, 89 insertions(+), 48 deletions(-) diff --git a/packages/fiori/cypress/specs/DynamicPage.cy.tsx b/packages/fiori/cypress/specs/DynamicPage.cy.tsx index 871843e8f71c..4f20216a9020 100644 --- a/packages/fiori/cypress/specs/DynamicPage.cy.tsx +++ b/packages/fiori/cypress/specs/DynamicPage.cy.tsx @@ -224,6 +224,58 @@ describe("DynamicPage", () => { .find("[ui5-dynamic-page-header-actions]") .should("have.prop", "hidePinButton", true); }); + + it("sets scroll padding when content receives focus", () => { + cy.mount( + + +
Page Title
+
+ +
Header Content
+
+ + + + +
+ ); + + cy.get("[data-testid='test-input']").focus(); + + cy.get("[ui5-dynamic-page]") + .shadow() + .find(".ui5-dynamic-page-scroll-container") + .should("have.css", "scroll-padding-top") + .and("not.equal", "0px"); + + cy.get("[data-testid='test-input']").blur(); + + cy.get("[ui5-dynamic-page]") + .shadow() + .find(".ui5-dynamic-page-scroll-container") + .should("have.css", "scroll-padding-top", "0px"); + }); + + it("scrolls focused elements into view", () => { + cy.mount( + + +
Page Title
+
+ +
Header Content
+
+
+ +
+
+ ); + + cy.get("[data-testid='bottom-input']").focus(); + + cy.get("[data-testid='bottom-input']").should("be.visible"); + }); }); describe("Scroll", () => { diff --git a/packages/fiori/src/DynamicPage.ts b/packages/fiori/src/DynamicPage.ts index 9142c9530a78..aa41142c64ed 100644 --- a/packages/fiori/src/DynamicPage.ts +++ b/packages/fiori/src/DynamicPage.ts @@ -188,7 +188,6 @@ class DynamicPage extends UI5Element { skipSnapOnScroll = false; showHeaderInStickArea = false; isToggled = false; - _focusInHandler?: (e: FocusEvent) => void; @property({ type: Boolean }) _headerSnapped = false; @@ -213,52 +212,16 @@ class DynamicPage extends UI5Element { if (this.dynamicPageHeader) { this.dynamicPageHeader._snapped = this._headerSnapped; } - const titleHeight = this.dynamicPageTitle?.getBoundingClientRect().height || 0; - const headerHeight = this.dynamicPageHeader?.getBoundingClientRect().height || 0; - const footerHeight = this.showFooter ? this.footerWrapper?.getBoundingClientRect().height : 0; - - if (this.scrollContainer) { - this.scrollContainer.style.setProperty("scroll-padding-block-end", `${footerHeight}px`); - - if (this._headerSnapped) { - this.scrollContainer.style.setProperty("scroll-padding-block-start", `${titleHeight}px`); - } else { - this.scrollContainer.style.setProperty("scroll-padding-block-start", `${headerHeight + titleHeight}px`); - } - } } - onAfterRendering() { - if (this.scrollContainer) { - if (this._focusInHandler) { - this.scrollContainer.removeEventListener("focusin", this._focusInHandler); - } - - this._focusInHandler = (e: FocusEvent) => { - const target = e.target as HTMLElement; - - if (!target || target === this.scrollContainer) { - return; - } - - if (this.dynamicPageHeader?.contains(target) || this.dynamicPageTitle?.contains(target)) { - return; - } - - requestAnimationFrame(() => { - target.scrollIntoView({ behavior: "smooth", block: "nearest" }); - }); - }; - - this.scrollContainer.addEventListener("focusin", this._focusInHandler); - } + get endAreaHeight() { + return this.showFooter ? this.footerWrapper?.getBoundingClientRect().height || 0 : 0; } - onExitDOM() { - if (this.scrollContainer && this._focusInHandler) { - this.scrollContainer.removeEventListener("focusin", this._focusInHandler); - this._focusInHandler = undefined; - } + get topAreaHeight() { + const titleHeight = this.dynamicPageTitle?.getBoundingClientRect().height || 0; + const headerHeight = this.dynamicPageHeader?.getBoundingClientRect().height || 0; + return this._headerSnapped ? titleHeight : headerHeight + titleHeight; } get dynamicPageTitle(): DynamicPageTitle | null { @@ -464,14 +427,35 @@ class DynamicPage extends UI5Element { } } - async onExpandHoverIn() { + onExpandHoverIn() { this.dynamicPageTitle?.setAttribute("hovered", ""); - await renderFinished(); } - async onExpandHoverOut() { + onExpandHoverOut() { this.dynamicPageTitle?.removeAttribute("hovered"); - await renderFinished(); + } + + onContentFocusIn(e: FocusEvent) { + const target = e.target as HTMLElement; + this.setScrollPadding({ start: this.topAreaHeight, end: this.endAreaHeight }); + // textareas and similar elements appear "in view" even when partially + // hidden behind sticky header/footer. + // manual scroll brings them fully into view. + // another issue is that browsers do not reflect dynamic changes of scroll-padding + requestAnimationFrame(() => { + target.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + } + + onContentFocusOut() { + // Reset scroll padding when focus leaves content (e.g., moves to sticky header). + // The sticky header is part of the scrollable area, so keeping padding causes unwanted scroll. + this.setScrollPadding({ start: 0, end: 0 }); + } + + setScrollPadding(padding: { start: number, end: number }) { + this.scrollContainer?.style.setProperty("scroll-padding-top", `${padding.start}px`); + this.scrollContainer?.style.setProperty("scroll-padding-bottom", `${padding.end}px`); } } diff --git a/packages/fiori/src/DynamicPageTemplate.tsx b/packages/fiori/src/DynamicPageTemplate.tsx index 895af65811ff..79a321a06f37 100644 --- a/packages/fiori/src/DynamicPageTemplate.tsx +++ b/packages/fiori/src/DynamicPageTemplate.tsx @@ -33,7 +33,12 @@ export default function DynamicPageTemplate(this: DynamicPage) { {!this.actionsInTitle && headerActions.call(this)} -
+
{this.showFooter &&