Collapsing Sticky Elements And Maintaining Scroll Offsets In Angular 9.0.0-rc.2
One of the best user experience (UX) features that GitHub has added recently is the "stickiness" of the file-header in the Pull Request (PR) review mode. And, part of what makes this feature so enjoyable is the fact that as the user collapses the content of a given file, the Window maintains its scroll offset so that the user doesn't lose their place within the PR. A few months ago, I experimented with creating a Sticky Position component in Angular; and now, to continue my "sticky" learning, I wanted to see if I could replicate the GitHub user experience: maintaining the Window's scroll offset after the content associated with a sticky header is collapsed in Angular 9.0.0-rc.2.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To understand the problem with naively collapsing content on a page, consider the following illustration that shows how the location of various elements on the page are changed when one of the elements is document-flow is collapsed:
As you can see, when one of the elements in the document-flow is collapsed, the elements below it can suddenly be scrolled out-of-view as they floats up to fill-up the space created by the collapsed element. To create a better user experience (UX), what we want to do is both collapse the element and - at the same time - scroll the window up so that the remnants of the collapsed element are at the top of the browser viewport. This way, the content below the collapsed element remains in view.
To explore this concept in Angular 9, I'm going to try and re-create the GitHub PR experience, where the header of each section is sticky; and, upon clicking on the header, the body of the given section is collapsed:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface Section {
title: string;
content: string;
isCollapsed: boolean;
}
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<ng-template ngFor let-section [ngForOf]="sections">
<!--
NOTE: A template variable (#sectionRef) is local to the current Template,
which, in this case, is the ngFor template. That means that each
iteration of the ngFor loop will get its own unique copy of #sectionRef.
-->
<section #sectionRef class="section">
<header class="section__header">
<a
(click)="toggleSection( section, sectionRef )"
class="section__toggle">
{{ section.title }}
</a>
</header>
<div
*ngIf="( ! section.isCollapsed )"
class="section__body">
{{ section.content }}
{{ "And more sweet copy over here. " | repeatString:1000 }}
</div>
</section>
</ng-template>
`
})
export class AppComponent {
public sections: Section[];
// I initialize the app component.
constructor() {
this.sections = [
{ title: "Section 1 Title", content: "Section 1 copy.", isCollapsed: false },
{ title: "Section 2 Title", content: "Section 2 copy.", isCollapsed: false },
{ title: "Section 3 Title", content: "Section 3 copy.", isCollapsed: false },
{ title: "Section 4 Title", content: "Section 4 copy.", isCollapsed: false },
{ title: "Section 5 Title", content: "Section 5 copy.", isCollapsed: false },
{ title: "Section 6 Title", content: "Section 6 copy.", isCollapsed: false },
{ title: "Section 7 Title", content: "Section 7 copy.", isCollapsed: false },
{ title: "Section 8 Title", content: "Section 8 copy.", isCollapsed: false },
{ title: "Section 9 Title", content: "Section 9 copy.", isCollapsed: false },
{ title: "Section 10 Title", content: "Section 10 copy.", isCollapsed: false }
];
}
// ---
// PUBLIC METHODS.
// ---
// I toggle the given section's content body.
public toggleSection(
section: Section,
sectionElement: HTMLElement
) : void {
section.isCollapsed = ! section.isCollapsed;
// If we're collapsing the content of the given section, it may dramatically
// change the page-offset for the user. As such, if the section is currently
// located above the viewport, we want to SCROLL THE WINDOW UP to the top of
// the section element such that the content below the collapsed element
// remains visible to the user.
if ( section.isCollapsed ) {
var rect = sectionElement.getBoundingClientRect();
// If the section element is ABOVE THE VIEWPORT, adjust the scroll.
if ( rect.top < 0 ) {
window.scrollBy( 0, ( rect.top - 4 ) );
// NOTE: The (-4) is to adjust for the CSS styling of the page.
}
}
}
}
The approach here is relatively straightforward. When the user clicks on the Section header, I pass the Element reference into the toggleSection()
method. I then use this Element reference to figure out where the Section is rendered, relative to the Viewport (using .getBoundingClientRect()
). And, if the Section is rendered above the Viewport's top-fold, I simply scroll the window up using the magnitude of said offset.
Now, if we run this Angular 9.0.0-rc.2 application in the browser and try to collapse a few sections, you'll notice that head Section header "sticks" to the top of the viewport when it is collapsed:
As you can see, when we collapse "Section 1", the window is scrolled-up so that the newly-collapsed "Section 1" is at the top of the Browser's viewport. This leaves the content of "Section 2" just below it, firmly within the User's sight. This creates a much more enjoyable user experience (compared to having "Section 2" scroll-up past the top-fold of the Viewport).
This was a fun little code kata. I really appreciate the effort that the GitHub team has put into the Pull Review experience. And, I'm happy to be able to reproduce some of that "sticky position" magic with Angular 9.0.0-rc.2.
Want to use code from this post? Check out the license.
Reader Comments
@All,
As a quick follow-up to this, I wanted to explore a somewhat-related topic of maintaining the user's current "scroll offset" even when content is added above the viewport:
www.bennadel.com/blog/3724-maintaining-scroll-offsets-when-adding-content-above-the-user-s-viewport-in-angular-9-0-0-rc-2.htm
One awesome thing I discovered during this demo is that Chrome and Firefox actually do this for you automatically - woot woot! However, with Safari and Chrome, we actually have to step in an do this programmatically.