How to use AnchorTo in Terra projects
β AnchorTo
Section titled ββ AnchorToβAnchorTo is a class made to scroll us from one part of the page to another.
We use it from this package available in the @terrahq set of libraries for development.
This is helpful for subnavs, navigation links on a sidebar, any point at the page where you need to quickly direct the user to another point of the same page.
But in Terra, we are used to load every library only when we need to use it, so we might run into the issue that our libraries are not available or loaded when we need them.
π Use cases
Section titled βπ Use casesβ- We have a subnav with links to several places of the page. This subnav is at the very top of the page. β οΈ
- We have a navigation sidebar with links to subsections in the page. To get to this sidebar we will always have to scroll first. β
- We enter the page with a # or scroll-to parameter in the URL that indicates the section where we need to go. β
π Issues
Section titled βπ IssuesβThe first case might cause some issues because our libraries will not be loaded until we scroll down on the page (we load all the libraries that are not in viewport on scroll using Boostify).
We are especially interested in the libraries that will affect the height of the page:
- We enter the page and load only the libraries we need at the very top.
- We have an accordion below - this library modifies the page height: when we enter the page all the text is displayed in block, when the library is instanced the accordion is formed and the height of the page changes.
- We need to go to a section that is below that accordion.
If we used the library as is, we would end up much lower than we intended to, because the DOM when we enter the page is not the same height as the DOM when the libraries are loaded.
β¨ Solution
Section titled ββ¨ Solutionβπ The concept
Section titled βπ The conceptβWe need to delay the scrolling until our height-modifying libraries are instanced. So when we click in a link:
- We flag to our CoreHandler that we are performing an anchor action
- We instance our libraries
- We scroll to the correct position in the page
π‘ Understanding how it works
Section titled βπ‘ Understanding how it worksβπ» In our code
Section titled βπ» In our codeβFirst, we need to make sure to indicate in Project.js which libraries modify the height of the page:
this.Manager.modifyHeight(["Slider", "Collapsify"]);After that, we need to pay attention to this part of our CoreHandler.js:
getLibraryName(name) { this.libraryName = name; if (this.Manager.librariesHeight && this.Manager.librariesHeight.includes(name)) this.modifyHeight = true; }When the handler obtains the library name, it will also check if the library it is using is declared as one of the libraries that modify the height of the page, and it will store that information.
async assignInstances(payload) { const { elementGroups, anchor } = payload;Here we can see an anchor parameter that it will be using immediately afterwards:
if ( this.Manager.libraries.isElementInViewport({ el: element, debug: this.terraDebug, }) || (this.modifyHeight && anchor)) { this.createInstance({ element, config });}So basically, we are telling our CoreHandler that, either if:
- the element is in viewport when we load the page
- the library this handler belongs to is a library that modifies the height of the page and we have the anchor parameter
it needs to create instances of our library immediately
π In the library
Section titled βπ In the libraryβThe AnchorTo library receives two special Terra-exclusive parameters:
- heightModifyingLibraries β> an array of libraries (this is the same array that we are declaring in
Project.js) - Manager β> our Manager that we use across our whole application
And it uses them to power this function:
waitForHeightModifyingLibraries() { return new Promise((resolve) => { // If no height-modifying libraries are registered, resolve immediately if (this.heightModifyingLibraries.length === 0) { this.debug && console.log("No height-modifying libraries registered"); resolve(); return; }
// If no Manager instance provided, resolve immediately if (!this.Manager) { this.debug && console.log("No Manager instance provided, proceeding with scroll"); resolve(); return; }
let checkCount = 0; const maxChecks = 20; // Maximum 1 second wait (20 * 50ms)
const checkLibrariesLoaded = () => { checkCount++;
// Filter libraries that are actually loaded in Manager.libraries const availableLibraries = this.heightModifyingLibraries.filter( (libName) => this.Manager.libraries[libName] );
// If no libraries are available in Manager.libraries, resolve immediately if (availableLibraries.length === 0) { this.debug && console.log( "No height-modifying libraries available in Manager.libraries, proceeding with scroll" ); resolve(); return; }
// Check if all available height-modifying libraries have instances const allLoaded = availableLibraries.every( (libName) => this.Manager.instances[libName] && this.Manager.instances[libName].length > 0 );
if (allLoaded) { resolve(); } else if (checkCount >= maxChecks) { this.debug && console.warn("Timeout waiting for height-modifying libraries, proceeding with scroll"); resolve(); } else { // Check again after a short delay setTimeout(checkLibrariesLoaded, 50); } };
// Start checking for library completion setTimeout(checkLibrariesLoaded, 100); }); }This function returns a Promise.
First, it checks that we have the Manager and the array of libraries. If we donβt, it resolves the promise and the code goes on.
If we have them, we continue and make a series of checks until we have instances for all of our libraries.
What this function will do is give our CoreHandler time to instance all of those libraries we asked it to instance immediately, and resolve the Promise once everything is loaded.
This will allow our library to know when it can start scrolling.
To use this function we make use of a beforeScroll callback, that will be executed before the scroll action starts:
async handleClick(event) { event.preventDefault();
if (typeof this.beforeScroll === "function") {
// Wait for any async operation in beforeScroll to end await this.beforeScroll();
// Wait for height-modifying libraries to be loaded await this.waitForHeightModifyingLibraries();
// Re-query the destination element after libraries are loaded this.DOM.destination = document.getElementById(this.destinationSelector); }If we have this callback, we will execute any actions we have indicated in it, we will wait until our libraries are instanced, we will re-query the DOM to find the new position of the element, and then the library will continue its way and bring us there.
π Practical steps
Section titled βπ Practical stepsβNow, that structure is in place, but what do we need to do to ensure this works properly?
Ensure all libraries that modify the height of the page are declared in Project.js.
Create a Handler for our AnchorTo library and make sure we include everything we need in the configuration:
constructor(payload) { super(payload); this.init(); this.events(); this.config = (element) => ({ trigger: element, heightModifyingLibraries: this.Manager.librariesHeight, Manager: this.Manager, destination: document.getElementById(element.getAttribute("data-anchor-destination")), destinationSelector: element.getAttribute("data-anchor-destination"), offset: 250, url: "hash", speed: 500, emitEvents: true, popstate: true, debug: payload.terraDebug, beforeScroll: async () => { var tl = gsap.timeline(); tl.add(toggleSpinner({ direction: "up" })); this.emitter.emit("AnchorTo"); }, onComplete: async () => { var tl = gsap.timeline(); tl.add(toggleSpinner({ direction: "down" })); }, }); }It is important to provide:
- The destinationSelector β> this will allow the library to re-query the DOM after loading the libraries
- Our heightModifyingLibraries β> the array that we stored in the Manager in
Project.js - Our Manager β> so it can check if the libraries are available and if they are already instanced
And we have callbacks for our two lifecycle points:
- beforeScroll β> before the scroll action starts, here we need to flag to our application that it needs to instance the libraries immediately. In this example, aside from that, we are also showing a spinner.
- onComplete β> if we need to perform any actions after the scrolling has ended. In this example, we are hiding a spinner.
π© How do we let our application know that we need to instance those libraries?
Section titled βπ© How do we let our application know that we need to instance those libraries?βUntil now, the Terra framework has been based around two important moments in the lifecycle of our application:
- contentReplaced β> hooked to our Swup, after the content of the page has been replaced (this includes first load)
- willReplaceContent β> hooked to our Swup, before the content of the page disappears to go to the next one
Our handlers are instanced on first page load and then function listening to events from these two points.
But there are situations, like this one, where we need to intervene at specific points of the lifecycle to wake our handlers and make them perform actions.
Following our event-based handler architecture, we will use these to make this intervention.
- We will emit an event from our AnchorTo Handler β>
this.emitter.emit("AnchorTo"); - We will listen to that event in the handlers of our height-modifying libraries to make sure they are instantiated.
π© You have an issue with the AnchorTo and libraries that change the height?
Section titled βπ© You have an issue with the AnchorTo and libraries that change the height?βconstructor(payload) { super(payload); this.init(); this.events(); this.config = (element) => ({ trigger: element, heightModifyingLibraries: this.Manager.librariesHeight, Manager: this.Manager, destination: document.getElementById(element.getAttribute("data-anchor-destination")), destinationSelector: element.getAttribute("data-anchor-destination"), offset: 250, url: "hash", speed: 500, emitEvents: true, popstate: true, debug: payload.terraDebug, beforeScroll: async () => { var tl = gsap.timeline(); tl.add(toggleSpinner({ direction: "up" })); this.emitter.emit("AnchorTo"); await new Promise((resolve) => setTimeout(resolve, 50)); // add this setTimeout to give more time to the heightModifyingLibraries to load and recalculate }, onComplete: async () => { var tl = gsap.timeline(); tl.add(toggleSpinner({ direction: "down" })); }, }); }this.emitter.on("AnchorTo", () => { if (this.Manager.instances["Collapsify"].length === 0) { this.DOM = this.updateTheDOM; // Re-query elements each time this is called
super.assignInstances({ elementGroups: [ { elements: this.DOM.accordionElements, config: this.config, }, ], anchor: true, }); }});We will listen for our new event in the handler of, for instance, our Collapsify library and call to our CoreHandler.js so it can instance the elements we need (the ones we have present in the page where we are using our anchor).
The check for existing instances prevents the handler from re-instancing them every time someone uses the anchor, only does it if the instances do not exist already.
This re-queries the DOM, gets the elements needed, and calls CoreHandler with the anchor parameter so it instances the library immediately.
We will do this in all the handlers of the libraries that modify the height of our page and are present in the page where the anchor is being used.
π Recap
Section titled βπ RecapβAnd that is it! We have:
- Our AnchorTo class with its own internal detection of libraries being instanced
- Our modifying height libraries in Project.js
- Our CoreHandler detecting if a library modifies the height of the page and if it has the anchor parameter
- Our AnchorTo Handler instancing the AnchorTo class and emitting an event in its beforeScroll callback
- Our handlers for our height-modifying libraries listening to that event to re-trigger instancing through CoreHandler