docbrown/app/javascript/utilities/dropdownUtils.js
Josh Soref 0403e78f08
Spelling (#15702)
* spelling: access

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: additional

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: administrative

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: aggregate

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: assigns

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: attributes

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: autocomplete

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: because

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: between

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: bootstrap

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: calculating

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: captcha

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: character

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: chosen

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: commenter

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: competitor

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: componentize

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: contrast

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: corresponding

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: description

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: destroyed

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: destroys

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: discussion

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: episode

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: escaped

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: evaluates

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: expired

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: explicitly

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: facebook

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: fragment

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: functionality

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: improper

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: incentive

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: interfere

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: latest

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: message

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: minimum

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: moderator

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: mouseover

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: mutual

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: nonexistent

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: notification

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: occasionally

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: occurrence

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: occurs

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: octokit

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: offset

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: omitted

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: opacity

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: organization

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: organizations

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: overridden

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: override

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: overriding

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: prefill

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: previous

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: profile

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: recycling

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: registered

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: repositories

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: rescuing

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: response

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: returns

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: second

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: separator

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: services

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: subscriber

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: subscription

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: success

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: successful

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: successfully

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: suppress

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: test

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: thought

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: uniqueness

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: unknown

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: unproductive

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: unreachable

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: unsuccessful

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: utilities

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: utility

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: valid

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: voluntarily

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: vomited

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: website

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

* spelling: withholding

Signed-off-by: Josh Soref <jsoref@users.noreply.github.com>

Co-authored-by: Josh Soref <jsoref@users.noreply.github.com>
2021-12-07 06:59:10 -07:00

241 lines
8.1 KiB
JavaScript

import { isInViewport } from '@utilities/viewport';
import { debounceAction } from '@utilities/debounceAction';
/**
* Helper function designed to be used on scroll to detect when dropdowns should switch from dropping downwards/upwards.
* The action is debounced since scroll events are usually fired several at a time.
*
* @returns {Function} a debounced function that handles the repositioning of dropdowns
* @example
*
* document.addEventListener('scroll', getDropdownRepositionListener());
*/
export const getDropdownRepositionListener = () =>
debounceAction(handleDropdownRepositions);
/**
* Checks for all dropdowns on the page which have the attribute 'data-repositioning-dropdown', signalling
* they should dynamically change between dropping downwards or upwards, depending on viewport position.
*
* Any dropdowns not fully in view when dropping down will be switched to dropping upwards.
*/
const handleDropdownRepositions = () => {
// Select all of the dropdowns which should reposition
const allRepositioningDropdowns = document.querySelectorAll(
'[data-repositioning-dropdown]',
);
for (const element of allRepositioningDropdowns) {
// Default to dropping downwards
element.classList.remove('reverse');
const isDropdownCurrentlyOpen = element.style.display === 'block';
if (!isDropdownCurrentlyOpen) {
// We can't determine position on an element with display:none, so we "show" the dropdown with 0 opacity very temporarily
element.style.opacity = 0;
element.style.display = 'block';
}
if (!isInViewport({ element })) {
// If the element isn't fully visible when dropping down, reverse the direction
element.classList.add('reverse');
}
if (!isDropdownCurrentlyOpen) {
// Revert the temporary changes to determine position
element.style.removeProperty('display');
element.style.removeProperty('opacity');
}
}
};
/**
* Helper query string to identify interactive/focusable HTML elements
*/
const INTERACTIVE_ELEMENTS_QUERY =
'button, [href], input:not([type="hidden"]), select, textarea, [tabindex="0"]';
/**
* Open the given dropdown, updating aria attributes, and focusing the first interactive element
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContent The id of the dropdown content element
*/
const openDropdown = ({ triggerElementId, dropdownContentId }) => {
const dropdownContent = document.getElementById(dropdownContentId);
const triggerElement = document.getElementById(triggerElementId);
triggerElement.setAttribute('aria-expanded', 'true');
// Style set inline to prevent specificity issues
dropdownContent.style.display = 'block';
// Send focus to the first suitable element
dropdownContent.querySelector(INTERACTIVE_ELEMENTS_QUERY)?.focus();
};
/**
* Close the given dropdown, updating aria attributes
*
* @param {Object} args
* @param {string} args.triggerElementId The id of the button which activates the dropdown
* @param {string} args.dropdownContent The id of the dropdown content element
* @param {Function} args.onClose Optional function for any side-effects which should occur on dropdown close
*/
const closeDropdown = ({ triggerElementId, dropdownContentId, onClose }) => {
const dropdownContent = document.getElementById(dropdownContentId);
if (!dropdownContent) {
// Component may have unmounted
return;
}
document
.getElementById(triggerElementId)
?.setAttribute('aria-expanded', 'false');
// Remove the inline style added when we opened the dropdown
dropdownContent.style.removeProperty('display');
onClose?.();
};
/**
* A helper function to initialize dropdown behaviors. This function attaches open/close click and keyup listeners,
* and makes sure relevant aria properties and keyboard focus are updated.
*
* @param {Object} args
* @param {string} args.triggerButtonElementId The ID of the button which triggers the dropdown open/close behavior
* @param {string} args.dropdownContentId The ID of the dropdown content which should open/close on trigger button press
* @param {string} args.dropdownContentCloseButtonId Optional ID of any button within the dropdown content which should close the dropdown
* @param {Function} args.onClose An optional callback for when the dropdown is closed. This can be passed to execute any side-effects required when the dropdown closes.
* @param {Function} args.onOpen An optional callback for when the dropdown is opened. This can be passed to execute any side-effects required when the dropdown opens.
*
* @returns {{closeDropdown: Function}} Object with callback to close the initialized dropdown
*/
export const initializeDropdown = ({
triggerElementId,
dropdownContentId,
dropdownContentCloseButtonId,
onClose,
onOpen,
}) => {
const triggerButton = document.getElementById(triggerElementId);
const dropdownContent = document.getElementById(dropdownContentId);
if (!triggerButton || !dropdownContent) {
// The required props haven't been provided, do nothing
return;
}
// Ensure default values have been applied
triggerButton.setAttribute('aria-expanded', 'false');
triggerButton.setAttribute('aria-controls', dropdownContentId);
triggerButton.setAttribute('aria-haspopup', 'true');
const keyUpListener = ({ key }) => {
if (key === 'Escape') {
// Close the dropdown and return focus to the trigger button to prevent focus being lost
const isCurrentlyOpen =
triggerButton.getAttribute('aria-expanded') === 'true';
if (isCurrentlyOpen) {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
triggerButton.focus();
}
} else if (key === 'Tab') {
// Close the dropdown if the user has tabbed away from it
const isInsideDropdown = dropdownContent?.contains(
document.activeElement,
);
if (!isInsideDropdown) {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
}
}
};
// Close the dropdown if user has clicked outside
const clickOutsideListener = ({ target }) => {
if (
target !== triggerButton &&
!dropdownContent.contains(target) &&
!triggerButton.contains(target)
) {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
// If the user did not click on another interactive item, return focus to the trigger
if (!target.matches(INTERACTIVE_ELEMENTS_QUERY)) {
triggerButton.focus();
}
}
};
// Any necessary side effects required on dropdown close
const onCloseCleanupActions = () => {
onClose?.();
document.removeEventListener('keyup', keyUpListener);
document.removeEventListener('click', clickOutsideListener);
};
// Add the main trigger button toggle functionality
triggerButton.addEventListener('click', () => {
if (
document
.getElementById(triggerElementId)
?.getAttribute('aria-expanded') === 'true'
) {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
} else {
openDropdown({
triggerElementId,
dropdownContentId,
});
onOpen?.();
document.addEventListener('keyup', keyUpListener);
document.addEventListener('click', clickOutsideListener);
}
});
if (dropdownContentCloseButtonId) {
// The dropdown content has a 'close' button inside that we also need to handle
document
.getElementById(dropdownContentCloseButtonId)
?.addEventListener('click', () => {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
document.getElementById(triggerElementId)?.focus();
});
}
return {
closeDropdown: () => {
closeDropdown({
triggerElementId,
dropdownContentId,
onClose: onCloseCleanupActions,
});
},
};
};