From 5905a9f487ebfb9d4b873498167432cb4b1a8e7b Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Wed, 13 May 2026 11:27:27 +0300 Subject: [PATCH 1/3] fix: implement scroll locking during filter input and reset actions --- src/components/DocsSidebar/sidebar-filter.ts | 38 ++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/components/DocsSidebar/sidebar-filter.ts b/src/components/DocsSidebar/sidebar-filter.ts index 7d18988..32b5ba3 100644 --- a/src/components/DocsSidebar/sidebar-filter.ts +++ b/src/components/DocsSidebar/sidebar-filter.ts @@ -30,6 +30,9 @@ const SCROLL_SELECTOR = '[data-sidebar-scroll]'; const ITEM_SELECTOR = 'igc-tree-item[data-path]'; const GROUP_SELECTOR = 'igc-tree-item[data-group-key]'; +/** Frames over which `withDocumentScrollLocked` pins scrollY. ~250ms @ 60fps — long enough to interrupt the browser's smooth-scroll animation no matter which frame it starts on. */ +const SCROLL_LOCK_FRAMES = 15; + let _isClientSideNav = false; // ── Storage helpers ────────────────────────────────────────────────────────── @@ -158,11 +161,32 @@ class SidebarFilter extends HTMLElement { private onFilterInput = (): void => { safeSet(FILTER_KEY, this.input.value); - this.applyFilter(this.input.value, 'user'); + this.withDocumentScrollLocked(() => this.applyFilter(this.input.value, 'user')); }; + /** + * Pin scrollY across fn(). Chrome-only workaround: typing in a + * sticky-contained input triggers a browser-internal scroll that ignores + * `scroll-margin` / `overflow-anchor`, animated by global + * `scroll-behavior: smooth`. No CSS fix; snap back for a short window. + */ + private withDocumentScrollLocked(fn: () => void): void { + const savedY = window.scrollY; + fn(); + let framesLeft = SCROLL_LOCK_FRAMES; + const pin = (): void => { + if (window.scrollY !== savedY) { + window.scrollTo({ top: savedY, behavior: 'instant' as ScrollBehavior }); + } + if (--framesLeft > 0) requestAnimationFrame(pin); + }; + pin(); + } + private onFilterKeydown = (e: KeyboardEvent): void => { - if (e.key === 'Escape' && this.input.value) this.resetFilter(); + this.withDocumentScrollLocked(() => { + if (e.key === 'Escape' && this.input.value) this.resetFilter(); + }); }; private onClearClick = (): void => { @@ -381,10 +405,12 @@ class SidebarFilter extends HTMLElement { } private resetFilter(): void { - this.input.value = ''; - this.syncClearButton(''); - safeRemove(FILTER_KEY); - this.exitFilterMode(); + this.withDocumentScrollLocked(() => { + this.input.value = ''; + this.syncClearButton(''); + safeRemove(FILTER_KEY); + this.exitFilterMode(); + }); } } From f14b67f0af7af82552cbd7b87e0e1376d19a47fd Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 29 May 2026 17:05:11 +0300 Subject: [PATCH 2/3] fix(sidebar-filter): pin scrollY on input mutations to defeat Chrome scroll-into-view Replace the 15-frame rAF scroll-lock with a leaner combo: disable smooth scroll while the input has focus (so the unwanted scroll is a single instant jump, not a 250ms animation) and pin scrollY for ~200ms after each keystroke, extending the window on held-key repeats to cover Lit's batched re-renders. --- src/components/DocsSidebar/sidebar-filter.ts | 66 ++++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/components/DocsSidebar/sidebar-filter.ts b/src/components/DocsSidebar/sidebar-filter.ts index 0e3d7ff..2d90534 100644 --- a/src/components/DocsSidebar/sidebar-filter.ts +++ b/src/components/DocsSidebar/sidebar-filter.ts @@ -30,9 +30,6 @@ const SCROLL_SELECTOR = '[data-sidebar-scroll]'; const ITEM_SELECTOR = 'igc-tree-item[data-path]'; const GROUP_SELECTOR = 'igc-tree-item[data-group-key]'; -/** Frames over which `withDocumentScrollLocked` pins scrollY. ~250ms @ 60fps — long enough to interrupt the browser's smooth-scroll animation no matter which frame it starts on. */ -const SCROLL_LOCK_FRAMES = 15; - let _isClientSideNav = false; // ── Storage helpers ────────────────────────────────────────────────────────── @@ -153,14 +150,17 @@ class SidebarFilter extends HTMLElement { // ── Events ─────────────────────────────────────────────────────────────── private bindEvents(): void { - // igc-input fires 'igcInput' instead of the native 'input' event. - this.input.addEventListener('igcInput', this.onFilterInput as EventListener); + this.input.addEventListener('igcInput', this.onFilterInput as EventListener); this.input.addEventListener('keydown', this.onFilterKeydown); + this.input.addEventListener('focusin', this.onFilterFocus); + this.input.addEventListener('focusout', this.onFilterBlur); this.clearBtn.addEventListener('click', this.onClearClick); - this.bindTreeEvents(); } + private onFilterFocus = (): void => { document.documentElement.style.setProperty('scroll-behavior', 'auto', 'important'); }; + private onFilterBlur = (): void => { document.documentElement.style.removeProperty('scroll-behavior'); }; + /** Bind only the tree expand/collapse events (no filter input). */ private bindTreeEvents(): void { // igcItemExpanded / igcItemCollapsed are dispatched on with @@ -170,33 +170,34 @@ class SidebarFilter extends HTMLElement { } private onFilterInput = (): void => { + this.pinScrollY(); safeSet(FILTER_KEY, this.input.value); - this.withDocumentScrollLocked(() => this.applyFilter(this.input.value, 'user')); + this.applyFilter(this.input.value, 'user'); + }; + + private onFilterKeydown = (e: KeyboardEvent): void => { + this.pinScrollY(); + if (e.key === 'Escape' && this.input.value) this.resetFilter(); }; /** - * Pin scrollY across fn(). Chrome-only workaround: typing in a - * sticky-contained input triggers a browser-internal scroll that ignores - * `scroll-margin` / `overflow-anchor`, animated by global - * `scroll-behavior: smooth`. No CSS fix; snap back for a short window. + * Chrome-only: input mutations in this sticky-contained input trigger a + * browser-internal scroll. Snap scrollY back every frame for ~200ms, long + * enough to cover Lit's batched re-renders after exitFilterMode. rAF runs + * before paint so the snap is invisible. Each call resets `pinFrames`, so + * held-key repeats extend the window rather than starting parallel loops. */ - private withDocumentScrollLocked(fn: () => void): void { - const savedY = window.scrollY; - fn(); - let framesLeft = SCROLL_LOCK_FRAMES; - const pin = (): void => { - if (window.scrollY !== savedY) { - window.scrollTo({ top: savedY, behavior: 'instant' as ScrollBehavior }); - } - if (--framesLeft > 0) requestAnimationFrame(pin); + private pinFrames = 0; + private pinScrollY = (): void => { + const wasActive = this.pinFrames > 0; + const y = window.scrollY; + this.pinFrames = 10; + if (wasActive) return; + const tick = (): void => { + if (window.scrollY !== y) window.scrollTo({ top: y, behavior: 'instant' as ScrollBehavior }); + if (--this.pinFrames > 0) requestAnimationFrame(tick); }; - pin(); - } - - private onFilterKeydown = (e: KeyboardEvent): void => { - this.withDocumentScrollLocked(() => { - if (e.key === 'Escape' && this.input.value) this.resetFilter(); - }); + tick(); }; private onClearClick = (): void => { @@ -419,12 +420,11 @@ class SidebarFilter extends HTMLElement { } private resetFilter(): void { - this.withDocumentScrollLocked(() => { - this.input.value = ''; - this.syncClearButton(''); - safeRemove(FILTER_KEY); - this.exitFilterMode(); - }); + this.pinScrollY(); + this.input.value = ''; + this.syncClearButton(''); + safeRemove(FILTER_KEY); + this.exitFilterMode(); } } From 320b22bc465812f110fcaa337710c26ac44ce4c2 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 29 May 2026 18:07:53 +0300 Subject: [PATCH 3/3] fix(sidebar-filter): move scroll-behavior toggle into pinScrollY Previously the focus-time scroll-behavior toggle didn't cover the X clear button (focus leaves the input before resetFilter runs, restoring smooth mid-mutation and causing a visible stutter). Move the toggle into pinScrollY itself so 'auto' is in effect for exactly the pin window, regardless of focus state. Prior inline value (DocsLayout's 'smooth') is restored when the window ends. --- src/components/DocsSidebar/sidebar-filter.ts | 22 +++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/DocsSidebar/sidebar-filter.ts b/src/components/DocsSidebar/sidebar-filter.ts index 2d90534..6934c03 100644 --- a/src/components/DocsSidebar/sidebar-filter.ts +++ b/src/components/DocsSidebar/sidebar-filter.ts @@ -152,15 +152,10 @@ class SidebarFilter extends HTMLElement { private bindEvents(): void { this.input.addEventListener('igcInput', this.onFilterInput as EventListener); this.input.addEventListener('keydown', this.onFilterKeydown); - this.input.addEventListener('focusin', this.onFilterFocus); - this.input.addEventListener('focusout', this.onFilterBlur); this.clearBtn.addEventListener('click', this.onClearClick); this.bindTreeEvents(); } - private onFilterFocus = (): void => { document.documentElement.style.setProperty('scroll-behavior', 'auto', 'important'); }; - private onFilterBlur = (): void => { document.documentElement.style.removeProperty('scroll-behavior'); }; - /** Bind only the tree expand/collapse events (no filter input). */ private bindTreeEvents(): void { // igcItemExpanded / igcItemCollapsed are dispatched on with @@ -181,21 +176,28 @@ class SidebarFilter extends HTMLElement { }; /** - * Chrome-only: input mutations in this sticky-contained input trigger a - * browser-internal scroll. Snap scrollY back every frame for ~200ms, long - * enough to cover Lit's batched re-renders after exitFilterMode. rAF runs - * before paint so the snap is invisible. Each call resets `pinFrames`, so - * held-key repeats extend the window rather than starting parallel loops. + * Chrome-only: filter mutations trigger a browser-internal scroll. Pin + * scrollY via rAF for ~200ms (before-paint, invisible) and force + * `scroll-behavior: auto` so it's an instant jump, not a smooth animation + * we'd have to fight each frame. Prior inline value is restored at end. */ private pinFrames = 0; + private prevScrollBehavior: string | null = null; private pinScrollY = (): void => { const wasActive = this.pinFrames > 0; const y = window.scrollY; this.pinFrames = 10; if (wasActive) return; + this.prevScrollBehavior = document.documentElement.style.getPropertyValue('scroll-behavior'); + document.documentElement.style.setProperty('scroll-behavior', 'auto', 'important'); const tick = (): void => { if (window.scrollY !== y) window.scrollTo({ top: y, behavior: 'instant' as ScrollBehavior }); if (--this.pinFrames > 0) requestAnimationFrame(tick); + else { + if (this.prevScrollBehavior) document.documentElement.style.setProperty('scroll-behavior', this.prevScrollBehavior); + else document.documentElement.style.removeProperty('scroll-behavior'); + this.prevScrollBehavior = null; + } }; tick(); };