* 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>
241 lines
8.1 KiB
JavaScript
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,
|
|
});
|
|
},
|
|
};
|
|
};
|