1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[CL-834] Use intersection observer to determine if content scrolls (#16099)

* use intersection observer to fix dynamic content load issue

* set up mock intersection observer

* Create reusable hasScrollable content util

* return null from resize to fix type error

* remove Observer mock

* return observable

* refactor util and remove resize

* use async pipe for observable in template

* remove comment left in error
This commit is contained in:
Bryan Cunningham
2025-09-12 12:05:28 -04:00
committed by GitHub
parent f20ed9f0e9
commit 279d16999a
5 changed files with 65 additions and 14 deletions

View File

@@ -65,9 +65,10 @@
}"
>
<ng-content select="[bitDialogContent]"></ng-content>
<div #scrollBottom></div>
</div>
</div>
@let isScrollable = isScrollable$ | async;
@let showFooterBorder =
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
<footer

View File

@@ -8,13 +8,17 @@ import {
viewChild,
input,
booleanAttribute,
AfterViewInit,
ElementRef,
DestroyRef,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrollableContent$ } from "../../utils/";
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
import { fadeIn } from "../animations";
import { DialogRef } from "../dialog.service";
@@ -39,11 +43,22 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
CdkScrollable,
],
})
export class DialogComponent implements AfterViewInit {
protected dialogRef = inject(DialogRef, { optional: true });
export class DialogComponent {
private readonly destroyRef = inject(DestroyRef);
private scrollableBody = viewChild.required(CdkScrollable);
private scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
protected dialogRef = inject(DialogRef, { optional: true });
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
protected isScrollable = false;
private scrollableBody$ = toObservable(this.scrollableBody);
private scrollBottom$ = toObservable(this.scrollBottom);
protected isScrollable$ = combineLatest([this.scrollableBody$, this.scrollBottom$]).pipe(
switchMap(([body, bottom]) =>
hasScrollableContent$(body.getElementRef().nativeElement, bottom.nativeElement),
),
);
/** Background color */
readonly background = input<"default" | "alt">("default");
@@ -105,13 +120,4 @@ export class DialogComponent implements AfterViewInit {
}
}
}
ngAfterViewInit() {
this.isScrollable = this.canScroll();
}
canScroll(): boolean {
const el = this.scrollableBody().getElementRef().nativeElement as HTMLElement;
return el.scrollHeight > el.clientHeight;
}
}

View File

@@ -0,0 +1,17 @@
import { Observable } from "rxjs";
/** IntersectionObserver Observable */
export const intersectionObserver$ = (
target: Element,
init: IntersectionObserverInit,
): Observable<IntersectionObserverEntry> => {
return new Observable((sub) => {
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
sub.next(e);
}
}, init);
io.observe(target);
return () => io.disconnect();
});
};

View File

@@ -0,0 +1,26 @@
import { Observable, animationFrameScheduler } from "rxjs";
import { auditTime, map, startWith, observeOn, distinctUntilChanged } from "rxjs/operators";
import { intersectionObserver$ } from "./dom-observables";
/**
* Utility to determine if an element has scrollable content.
* Returns an Observable that emits whenever scroll/resize/layout might change visibility
*/
export const hasScrollableContent$ = (
root: HTMLElement,
target: HTMLElement,
threshold: number = 1,
): Observable<boolean> => {
return intersectionObserver$(target, { root, threshold }).pipe(
startWith(null as IntersectionObserverEntry | null),
auditTime(0, animationFrameScheduler),
observeOn(animationFrameScheduler),
map((entry: IntersectionObserverEntry | null) => {
if (!entry) {
return root.scrollHeight > root.clientHeight;
}
return !entry.isIntersecting;
}),
distinctUntilChanged(),
);
};

View File

@@ -1,3 +1,4 @@
export * from "./aria-disable-element";
export * from "./function-to-observable";
export * from "./has-scrollable-content";
export * from "./i18n-mock.service";