import PropTypes from 'prop-types';
import { isInViewport } from '../../utilities/viewport';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
const NAVIGATION_UP_KEY = 'KeyK';
const NAVIGATION_DOWN_KEY = 'KeyJ';
const DIRECTIONS = {
UP: 'up',
DOWN: 'down',
};
/**
* Hook that registers a global key shortcut for 'j' and 'k' to navigate up and down in a list of items
*
* @example
* useListNavigation(
* ".crayons-story",
* "a[id^=article-link-]",
* "div.paged-stories,div.substories",
* )
*
* Note:
* To avoid conflicts, only one of these should be called per page.
*
* Note on waterfalls:
* In the next example, the waterfall container would be 'div.paged-stories':
*
*
*
*
* @param {string} itemSelector - The selector for the highest level container of an item
* @param {string} focusableSelector - The selector for the element that should be focused on
* @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
*/
export function useListNavigation(
itemSelector,
focusableSelector,
waterfallItemContainerSelector,
) {
function navigateInDirection(direction) {
navigate(
itemSelector,
focusableSelector,
waterfallItemContainerSelector,
direction,
);
}
useKeyboardShortcuts(
{
[NAVIGATION_UP_KEY]: () => navigateInDirection(DIRECTIONS.UP),
[NAVIGATION_DOWN_KEY]: () => navigateInDirection(DIRECTIONS.DOWN),
},
window,
{ timeout: 0 },
);
}
/**
* Calls a hook that registers global key event listeners for 'j' and 'k' to navigate up and down in a list of items
*
* @example
*
*
* Note:
* To avoid conflicts, only one of these should be called per page.
*
* Note on waterfalls:
* In the next example, the waterfall container would be 'div.paged-stories':
*
*
*
*
* @param {string} itemSelector - The selector for the highest level container of an item
* @param {string} focusableSelector - The selector for the element that should be focused on
* @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
*/
export function ListNavigation({
itemSelector,
focusableSelector,
waterfallItemContainerSelector,
}) {
useListNavigation(
itemSelector,
focusableSelector,
waterfallItemContainerSelector,
);
return null;
}
ListNavigation.propTypes = {
itemSelector: PropTypes.string.isRequired,
focusableSelector: PropTypes.string.isRequired,
waterfallItemContainerSelector: PropTypes.string,
};
/**
* Focuses on the next/previous element depending on the navigation direction
*
* @param {string} itemSelector - The selector for the highest level container of an item
* @param {string} focusableSelector - The selector for the element that should be focused on
* @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
* @param {string} direction - The navigation direction (up or down)
*/
function navigate(
itemSelector,
focusableSelector,
waterfallItemContainerSelector,
direction,
) {
const closestContainer = document.activeElement?.closest(itemSelector);
let nextContainer;
if (!closestContainer) {
nextContainer = getFirstVisibleElement(itemSelector);
}
if (!nextContainer) {
const getElementCallback =
direction === DIRECTIONS.UP ? getPreviousElement : getNextElement;
nextContainer = getElementCallback(
closestContainer,
itemSelector,
waterfallItemContainerSelector,
);
}
const nextFocusable = nextContainer?.querySelector(focusableSelector);
if (nextFocusable) {
nextFocusable.focus();
if (!isInViewport(nextFocusable, 64)) {
window.scrollTo({ top: nextContainer.offsetTop - 64 });
}
}
}
/**
* Gets the next element of a list that matches a selector
*
* @param {object} element - The current element
* @param {string} itemSelector - The selector for the highest level container of an item
* @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
*
* @returns {object} The next element to focus on
*/
function getNextElement(element, itemSelector, waterfallItemContainerSelector) {
const sibling = element?.nextElementSibling;
if (
sibling &&
!sibling.matches(`${itemSelector},${waterfallItemContainerSelector}`)
) {
return sibling.nextElementSibling;
}
return sibling;
}
/**
* Gets the previous element of a list that matches a selector
*
* @param {object} element - The current element
* @param {string} itemSelector - The selector for the highest level container of an item
* @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
*
* @returns {object} The previous element to focus on
*/
function getPreviousElement(
element,
itemSelector,
waterfallItemContainerSelector,
) {
if (!element) {
return null;
}
let sibling = element.previousElementSibling;
if (!sibling && waterfallItemContainerSelector) {
// reached the top of a waterfall level
sibling = element.closest(waterfallItemContainerSelector)
?.previousElementSibling;
}
if (sibling && !sibling.matches(itemSelector)) {
return sibling.previousElementSibling;
}
return sibling;
}
/**
* Gets the first visible element that matches a selector
*
* @param {string} selector - The CSS selector
*
* @returns {object} The first visible element
*/
function getFirstVisibleElement(selector) {
const elements = document.querySelectorAll(selector);
return Array.prototype.find.call(elements, (element) =>
isInViewport(element),
);
}