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({ element: nextFocusable, offsetTop: 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; } /** * Checks if the first completely visible element is present that matches a selector and returns if it is available * If that isn't visible, it looks for the partially visible element that is present and returns that * If no elements visible(like the banner case which could cover the entire viewport), we select the first element from the list * * @param {string} selector - The CSS selector * * @returns {object} The first visible element */ function getFirstVisibleElement(selector) { const elements = [...document.querySelectorAll(selector)]; const completelyVisibleFirstElement = elements.find((element) => isInViewport({ element }), ); if (completelyVisibleFirstElement) { return completelyVisibleFirstElement; } const partiallyVisibleFirstElement = elements.find((element) => isInViewport({ element, allowPartialVisibility: true }), ); if (partiallyVisibleFirstElement) { return partiallyVisibleFirstElement; } return elements[0]; }