Skip to content

Instantly share code, notes, and snippets.

@lizlux
Last active April 10, 2021 19:25
Show Gist options
  • Save lizlux/bf82423cb04aea4de676fcec1beffa78 to your computer and use it in GitHub Desktop.
Save lizlux/bf82423cb04aea4de676fcec1beffa78 to your computer and use it in GitHub Desktop.
Attempt at duplicating Amazon's triangle hover navigation menu
/**
* This plugin attempts to duplicate Amazon's triangle hover navigation menu.
* It's an adaptation of https://github.com/kamens/jQuery-menu-aim, written in Typescript, without jQuery
* See original plugin for documentation
*/
// tslint:disable no-use-before-declare
interface Options {
rowSelector?: string;
submenuSelector?: string;
submenuDirection?: string;
tolerance?: number;
enter?: CallableFunction | null;
exit?: CallableFunction | null;
activate?: CallableFunction | null;
deactivate?: CallableFunction | null;
exitMenu?: CallableFunction | null;
}
interface Coordinates {
x: number;
y: number;
}
const noopRowFunction = (row: HTMLElement): any => { };
export const dropdownNavMenuAim = function (elements: NodeList, opts: Options) {
// Initialize menu-aim for all elements in collection
Array.prototype.forEach.call(elements, function (element: HTMLElement) {
init.call(element, opts);
});
return this;
};
function init(opts: Options) {
const menu = this;
let timeoutId = null;
let activeRow: HTMLElement = null;
const mouseLocs = [];
let lastDelayLoc = null;
const options = Object.assign({}, {
rowSelector: '> li', // todo: hook this up properly
submenuSelector: null,
submenuDirection: 'right',
tolerance: 75, // bigger = more forgivey when entering submenu
enter: noopRowFunction,
exit: noopRowFunction,
activate: noopRowFunction,
deactivate: noopRowFunction,
exitMenu: noopRowFunction
}, opts);
const menuRows = menu.children;
const MOUSE_LOCS_TRACKED = 3; // number of past mouse locations to track
const DELAY = 300; // ms delay when user appears to be entering submenu
/**
* Keep track of the last few locations of the mouse.
*/
const mousemoveDocument = function (event: MouseEvent) {
mouseLocs.push({ x: event.pageX, y: event.pageY });
if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
mouseLocs.shift();
}
};
/**
* Cancel possible row activations when leaving the menu entirely
*/
const mouseleaveMenu = function () {
if (timeoutId) {
clearTimeout(timeoutId);
}
// If exitMenu is supplied and returns true, deactivate the
// currently active row on menu exit.
if (options.exitMenu(this)) {
if (activeRow) {
options.deactivate(activeRow);
}
activeRow = null;
}
};
/**
* Trigger a possible row activation whenever entering a new row.
*/
const mouseenterRow = function () {
if (timeoutId) {
// Cancel any previous activation delays
clearTimeout(timeoutId);
}
options.enter(this);
possiblyActivate(this);
};
const mouseleaveRow = function () {
options.exit(this);
};
/*
* Immediately activate a row if the user clicks on it.
*/
const clickRow = function () {
// activate(this); // TODO: make this an option
};
/**
* Activate a menu row.
*/
const activate = function (row: HTMLElement) {
if (row === activeRow) {
return;
}
if (activeRow) {
options.deactivate(activeRow);
}
options.activate(row);
activeRow = row;
};
/**
* Possibly activate a menu row. If mouse movement indicates that we
* shouldn't activate yet because user may be trying to enter
* a submenu's content, then delay and check again later.
*/
const possiblyActivate = function (row: HTMLElement) {
const delay = activationDelay();
if (delay) {
timeoutId = setTimeout(function () {
possiblyActivate(row);
}, delay);
} else {
activate(row);
}
};
/**
* Return the amount of time that should be used as a delay before the
* currently hovered row is activated.
*
* Returns 0 if the activation should happen immediately. Otherwise,
* returns the number of milliseconds that should be delayed before
* checking again to see if the row should be activated.
*/
const activationDelay = function () {
// TODO: double check on this
if (!activeRow || (options.submenuSelector && !activeRow.classList.contains(options.submenuSelector))) {
// If there is no other submenu row already active, then
// go ahead and activate immediately.
return 0;
}
const d = menu.getBoundingClientRect();
const upperLeft = {
x: d.left,
y: d.top - options.tolerance
};
const upperRight = {
x: d.right,
y: upperLeft.y
};
const lowerLeft = {
x: d.left,
y: d.bottom + options.tolerance
};
const lowerRight = {
x: d.right,
y: lowerLeft.y
};
const loc = mouseLocs[mouseLocs.length - 1];
let prevLoc = mouseLocs[0];
if (!loc) {
return 0;
}
if (!prevLoc) {
prevLoc = loc;
}
if (prevLoc.x < upperLeft.x || prevLoc.x > lowerRight.x ||
prevLoc.y < menu.offsetTop || prevLoc.y > lowerRight.y) {
// If the previous mouse location was outside of the entire
// menu's bounds, immediately activate.
return 0;
}
if (lastDelayLoc &&
loc.x === lastDelayLoc.x && loc.y === lastDelayLoc.y) {
// If the mouse hasn't moved since the last time we checked
// for activation status, immediately activate.
return 0;
}
// Detect if the user is moving towards the currently activated
// submenu.
//
// If the mouse is heading relatively clearly towards
// the submenu's content, we should wait and give the user more
// time before activating a new row. If the mouse is heading
// elsewhere, we can immediately activate a new row.
//
// We detect this by calculating the slope formed between the
// current mouse location and the upper/lower right points of
// the menu. We do the same for the previous mouse location.
// If the current mouse location's slopes are
// increasing/decreasing appropriately compared to the
// previous's, we know the user is moving toward the submenu.
//
// Note that since the y-axis increases as the cursor moves
// down the screen, we are looking for the slope between the
// cursor and the upper right corner to decrease over time, not
// increase (somewhat counterintuitively).
function slope(a: Coordinates, b: Coordinates) {
return (b.y - a.y) / (b.x - a.x);
}
let decreasingCorner = upperRight,
increasingCorner = lowerRight;
// Our expectations for decreasing or increasing slope values
// depends on which direction the submenu opens relative to the
// main menu. By default, if the menu opens on the right, we
// expect the slope between the cursor and the upper right
// corner to decrease over time, as explained above. If the
// submenu opens in a different direction, we change our slope
// expectations.
if (options.submenuDirection === 'left') {
decreasingCorner = lowerLeft;
increasingCorner = upperLeft;
} else if (options.submenuDirection === 'below') {
decreasingCorner = lowerRight;
increasingCorner = lowerLeft;
} else if (options.submenuDirection === 'above') {
decreasingCorner = upperLeft;
increasingCorner = upperRight;
}
const decreasingSlope = slope(loc, decreasingCorner);
const increasingSlope = slope(loc, increasingCorner);
const prevDecreasingSlope = slope(prevLoc, decreasingCorner);
const prevIncreasingSlope = slope(prevLoc, increasingCorner);
if (
decreasingSlope < prevDecreasingSlope &&
increasingSlope > prevIncreasingSlope
) {
// Mouse is moving from previous location towards the
// currently activated submenu. Delay before activating a
// new menu row, because user may be moving into submenu.
lastDelayLoc = loc;
return DELAY;
}
lastDelayLoc = null;
return 0;
};
/**
* Hook up initial menu events
*/
menu.addEventListener('mouseleave', mouseleaveMenu);
Array.prototype.forEach.call(menuRows, (menuRow: HTMLElement) => {
menuRow.addEventListener('mouseenter', mouseenterRow);
menuRow.addEventListener('mouseleave', mouseleaveRow);
menuRow.addEventListener('click', clickRow);
});
document.addEventListener('mousemove', mousemoveDocument);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment