Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2017 (Washington, D.C.) with: Justin Hinden
Ben Nadel at cf.Objective() 2017 (Washington, D.C.) with: Justin Hinden@JustinHinden )

Positioning And Constraining A Fixed-Position Element Relative To An Absolute-Positioned Element In Angular 9.0.0-rc.2

By Ben Nadel on

Historically, when I need to position a transient element, like a drop-down menu or a pop-up, I usually embed said element in the DOM (Document Object Model) directly next to its trigger element; and then, position the transient element "relative" to the trigger element using absolute positioning. And, for the most part, this works wonderfully. However, there are times when this limits the type of user experience (UX) that I can create. As such, I wanted to and get more comfortable with positioning related elements even when they don't exist in the same "coordinates plane". To explore this relationship, I am going to position a fixed-position element relative to an absolute-positioned element; and, constrain the fixed-position element within the browser's viewport 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.

For this demo, I'm going to keep things as simple as possible. All I have are two div elements. One using position:absolute and one using position:fixed. If you click on the absolute-position element, it will enter "drag" mode; and, as you drag the anchor around, the fixed-position element will render just below it. That is, until you reach the edges of the viewport, at which time the fixed-position element will remain just off the edge, regardless of where the anchor element is.

The basic premise here is that I calculate the position of the absolute-position element based on the user's mouse. Then, I translate those "document" coordinates into "viewport" coordinates by accounting for the current page scroll.

This works because the absolute-position element is positioned relative to the document root. If this were not the case - if the absolute-positioned element where positioned relative to a node lower-down in the DOM tree - I would use something like Element.getBoundingClientRect() to locate the "trigger" element and then go from there. I'll try that in a subsequent experiment; but for now, I'm assuming the trigger element is positioned relative to the document.

With that said, let's look at the code. The handleMousedown() method just sets-up the drag handlers and captures the local coordinates of the mouse relative to the "trigger" element. But, the bulk of the logic takes place in the handleMousemove() method - that's where we position the fixed-position element relative to the absolute-position element:

// Import the core angular services.
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
import { ViewChild } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface Position {
	left: number;
	top: number;
}

@Component({
	selector: "app-root",
	queries: {
		anchorRef: new ViewChild( "anchorRef" ),
		popupRef: new ViewChild( "popupRef" )
	},
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<div
			#anchorRef
			(mousedown)="handleMousedown( $event )"
			class="anchor"
			[style.left.px]="anchorPosition.left"
			[style.top.px]="anchorPosition.top">
			Drag Me
		</div>

		<div
			#popupRef
			class="popup"
			[class.is-constrained]="isConstrained"
			[style.left.px]="popupPosition.left"
			[style.top.px]="popupPosition.top">
			<br />
		</div>
	`
})
export class AppComponent {

	public anchorMouseOffset: Position;
	public anchorPosition: Position;
	public anchorRef!: ElementRef;
	public isConstrained: boolean;
	public popupPosition: Position;
	public popupRef!: ElementRef;

	// I initialize the app component.
	constructor() {

		this.anchorPosition = {
			left: 10,
			top: 120
		};
		this.anchorMouseOffset = {
			left: 0,
			top: 0
		};
		this.popupPosition = {
			left: -1000,
			top: -1000
		};
		this.isConstrained = false;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I handle the mousedown event on the anchor.
	public handleMousedown( event: MouseEvent ) : void {

		// Canceling the mousedown event helps prevent native "selection" and "dragging"
		// behaviors in the browser.
		event.preventDefault();

		// Calculate the local position of the mouse, relative to the top-left corner of
		// the anchor element. This will allows us to create a more natural drag-effect
		// by maintaining this local offset as we reposition the anchor relative to the
		// user's mouse.
		// --
		// NOTE: The client(X|Y) coordinates are relative to the browser's viewport,
		// regardless of the window scroll offset.
		var anchorRect = this.anchorRef.nativeElement.getBoundingClientRect();
		this.anchorMouseOffset.left = ( event.clientX - anchorRect.left );
		this.anchorMouseOffset.top = ( event.clientY - anchorRect.top );

		// PERFORMANCE NOTE: Since these events are being bound from within the NgZone,
		// it means that Angular will trigger a change-detection digest after each event.
		// If you wanted to have more control, you could bind these events outside of the
		// NgZone, and then dip back into the NgZone explicitly as needed. For the
		// purposes of this demo, I'm keeping things simple. Let's just let the digest
		// fire, as needed, since we're going to be updating the position a lot anyway.
		window.addEventListener( "mousemove", this.handleMousemove );
		window.addEventListener( "mouseup", this.handleMouseup );

	}


	// I handle the mousemove event on the window.
	public handleMousemove = ( event: MouseEvent ): void => {

		// We can position the anchor anywhere we want - we are putting NO CONSTRAINTS on
		// where it can be placed. If the user accidentally drops it off the side of the
		// page, the user can just refresh the page to get it back.
		// --
		// NOTE: The client(X|Y) coordinates are relative to the browser's viewport,
		// regardless of the window scroll offset. As such, in order to position the
		// anchor - which is using absolute positioning relative to the document - we are
		// translating the client coordinates into "page" coordinates by incorporating
		// the window's scroll offset (page[X|Y]Offset).
		this.anchorPosition.left = ( event.clientX - this.anchorMouseOffset.left + window.pageXOffset );
		this.anchorPosition.top = ( event.clientY - this.anchorMouseOffset.top + window.pageYOffset );

		// In order to prevent the popup from being positioned outside of the viewport
		// (as best we can), we have to know its dimensions. This way, we can limit
		// offsets as the popup approaches the edge of the viewport.
		// --
		// PERFORMANCE NOTE: For a small improvement, we could gather these dimensions
		// of the popup at the time we first render it. However, then we'd have to store
		// them as component properties and I wanted to keep this as simple as possible.
		var popupRect = this.popupRef.nativeElement.getBoundingClientRect();
		var popupWidth = popupRect.width;
		var popupHeight = popupRect.height;
		var windowWidth = document.documentElement.clientWidth;
		var windowHeight = document.documentElement.clientHeight;

		// NOTE: When positioning the popup, we are translating an ABSOLUTE position (the
		// anchor) into a FIXED position (the popup). As such, we have to take the window
		// scroll-offsets into account.

		// First, let's calculate the "natural" position of the popup relative to the
		// anchor. This would be the position if we didn't want to constrain the location
		// of the popup relative to the viewport.
		var naturalLeft = ( this.anchorPosition.left - window.pageXOffset );
		var naturalTop = ( this.anchorPosition.top + 40 - window.pageYOffset );

		// Second, let's calculate the constrained position of the popup relative to the
		// viewport (such that the popup doesn't overlap with the edge of the viewport).
		// --
		// NOTE: In the following calculations, the "10" is the distance we want to keep
		// the popup away from the edges of the viewport.
		var minLeft = 10;
		var maxLeft = ( windowWidth - popupWidth - 10 );
		var minTop = 10;
		var maxTop = ( windowHeight - popupHeight - 10 );

		// Make sure we don't go too far right or left.
		this.popupPosition.left = Math.min( naturalLeft, maxLeft );
		this.popupPosition.left = Math.max( minLeft, this.popupPosition.left );

		// Make sure we don't go too far down or up.
		this.popupPosition.top = Math.min( naturalTop, maxTop );
		this.popupPosition.top = Math.max( minLeft, this.popupPosition.top );

		// Check to see if the popup position has been constrained.
		this.isConstrained = (
			( this.popupPosition.left !== naturalLeft ) ||
			( this.popupPosition.top !== naturalTop )
		);

	}


	// I handle the mouseup event on the window. 
	public handleMouseup = () : void => {

		window.removeEventListener( "mousemove", this.handleMousemove );
		window.removeEventListener( "mouseup", this.handleMouseup );

		this.popupPosition.left = -1000;
		this.popupPosition.top = -1000;

	}

}

Within the handleMousemove() method, we first calculate the location of the popup element relative to the anchor element. Then we check to see if that calculated "natural" location would cause the popup to overlap with one of the edges of the viewport. And, if it would, we constrain the location of the popup to be 10px from that edge.

Now, if we run this Angular app in the browser and drag the anchor element around, we get the following output:

A popup elements is constrained by the viewport even when its drag-trigger moves outside the viewport in Angular 9.0.0-rc.2.

As you can see, even when the anchor elements is dragged outside of the browser's viewport, the popup elements never gets closer than 10px from the edge of the viewport. And, to make this extra clear, the border-color changes when the popup is being artificially constrained.

To some degree, I am over-complicating this by basing the fixed-position coordinates on the absolute-position coordinates. Really, this would have been easier if I just grabbed the .getBoundingClientRect() of the anchor in order to get the anchor's fixed-position location. But, this was still a helpful exploration for me as it re-familiarized me with the various Mouse and Window event data and coordinate system. And, hopefully some of you found it interesting as well.



Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.