You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

238 lines
9.7 KiB
JavaScript

import { get, writable } from 'svelte/store';
// Use a store to pass the Floating UI import references
export const storePopup = writable(undefined);
export function popup(triggerNode, args) {
// Floating UI Modules
const { computePosition, autoUpdate, offset, shift, flip, arrow, size, autoPlacement, hide, inline } = get(storePopup);
// Local State
const popupState = {
open: false,
autoUpdateCleanup: () => { }
};
const focusableAllowedList = ':is(a[href], button, input, textarea, select, details, [tabindex]):not([tabindex="-1"])';
let focusablePopupElements;
const documentationLink = 'https://www.skeleton.dev/utilities/popups';
// Elements
let elemPopup;
let elemArrow;
function setDomElements() {
elemPopup = document.querySelector(`[data-popup="${args.target}"]`) ?? document.createElement('div');
elemArrow = elemPopup.querySelector(`.arrow`) ?? document.createElement('div');
}
setDomElements(); // init
// Render Floating UI Popup
function render() {
// Error handling for required Floating UI modules
if (!elemPopup)
throw new Error(`The data-popup="${args.target}" element was not found. ${documentationLink}`);
if (!computePosition)
throw new Error(`Floating UI 'computePosition' not found for data-popup="${args.target}". ${documentationLink}`);
if (!offset)
throw new Error(`Floating UI 'offset' not found for data-popup="${args.target}". ${documentationLink}`);
if (!shift)
throw new Error(`Floating UI 'shift' not found for data-popup="${args.target}". ${documentationLink}`);
if (!flip)
throw new Error(`Floating UI 'flip' not found for data-popup="${args.target}". ${documentationLink}`);
if (!arrow)
throw new Error(`Floating UI 'arrow' not found for data-popup="${args.target}". ${documentationLink}`);
// Bundle optional middleware
const optionalMiddleware = [];
// https://floating-ui.com/docs/size
if (size)
optionalMiddleware.push(size(args.middleware?.size));
// https://floating-ui.com/docs/autoPlacement
if (autoPlacement)
optionalMiddleware.push(autoPlacement(args.middleware?.autoPlacement));
// https://floating-ui.com/docs/hide
if (hide)
optionalMiddleware.push(hide(args.middleware?.hide));
// https://floating-ui.com/docs/inline
if (inline)
optionalMiddleware.push(inline(args.middleware?.inline));
// Floating UI Compute Position
// https://floating-ui.com/docs/computePosition
computePosition(triggerNode, elemPopup, {
placement: args.placement ?? 'bottom',
// Middleware - NOTE: the order matters:
// https://floating-ui.com/docs/middleware#ordering
middleware: [
// https://floating-ui.com/docs/offset
offset(args.middleware?.offset ?? 8),
// https://floating-ui.com/docs/shift
shift(args.middleware?.shift ?? { padding: 8 }),
// https://floating-ui.com/docs/flip
flip(args.middleware?.flip),
// https://floating-ui.com/docs/arrow
arrow(args.middleware?.arrow ?? { element: elemArrow || null }),
// Implement optional middleware
...optionalMiddleware
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(elemPopup.style, {
left: `${x}px`,
top: `${y}px`
});
// Handle Arrow Placement:
// https://floating-ui.com/docs/arrow
if (elemArrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow;
// @ts-expect-error implicit any
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
}[placement.split('-')[0]];
Object.assign(elemArrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px'
});
}
});
}
// State Handlers
function open() {
if (!elemPopup)
return;
// Set open state to on
popupState.open = true;
// Return the current state
if (args.state)
args.state({ state: popupState.open });
// Update render settings
render();
// Update the DOM
elemPopup.style.display = 'block';
elemPopup.style.opacity = '1';
elemPopup.style.pointerEvents = 'auto';
// enable popup interactions
elemPopup.removeAttribute('inert');
// Trigger Floating UI autoUpdate (open only)
// https://floating-ui.com/docs/autoUpdate
popupState.autoUpdateCleanup = autoUpdate(triggerNode, elemPopup, render);
// Focus the first focusable element within the popup
focusablePopupElements = Array.from(elemPopup?.querySelectorAll(focusableAllowedList));
}
function close(callback) {
if (!elemPopup)
return;
// Set transition duration
const cssTransitionDuration = parseFloat(window.getComputedStyle(elemPopup).transitionDuration.replace('s', '')) * 1000;
setTimeout(() => {
// Set open state to off
popupState.open = false;
// Return the current state
if (args.state)
args.state({ state: popupState.open });
// Update the DOM
elemPopup.style.opacity = '0';
// disable popup interactions
elemPopup.setAttribute('inert', '');
// Cleanup Floating UI autoUpdate (close only)
if (popupState.autoUpdateCleanup)
popupState.autoUpdateCleanup();
// Trigger callback
if (callback)
callback();
}, cssTransitionDuration);
}
// Event Handlers
function toggle() {
popupState.open === false ? open() : close();
}
function onWindowClick(event) {
// Return if the popup is not yet open
if (popupState.open === false)
return;
// Return if click is the trigger element
if (triggerNode.contains(event.target))
return;
// If click it outside the popup
if (elemPopup && elemPopup.contains(event.target) === false) {
close();
return;
}
// Handle Close Query State
const closeQueryString = args.closeQuery === undefined ? 'a[href], button' : args.closeQuery;
const closableMenuElements = elemPopup?.querySelectorAll(closeQueryString);
closableMenuElements?.forEach((elem) => {
if (elem.contains(event.target))
close();
});
}
// Keyboard Interactions for A11y
const onWindowKeyDown = (event) => {
if (popupState.open === false)
return;
// Handle keys
const key = event.key;
// On Esc key
if (key === 'Escape') {
event.preventDefault();
triggerNode.focus();
close();
return;
}
// Update focusable elements (important for Autocomplete)
focusablePopupElements = Array.from(elemPopup?.querySelectorAll(focusableAllowedList));
// On Tab or ArrowDown key
const triggerMenuFocused = popupState.open && document.activeElement === triggerNode;
if (triggerMenuFocused &&
(key === 'ArrowDown' || key === 'Tab') &&
focusableAllowedList.length > 0 &&
focusablePopupElements.length > 0) {
event.preventDefault();
focusablePopupElements[0].focus();
}
};
// Event Listeners
switch (args.event) {
case 'click':
triggerNode.addEventListener('click', toggle, true);
window.addEventListener('click', onWindowClick, true);
break;
case 'hover':
triggerNode.addEventListener('mouseover', open, true);
triggerNode.addEventListener('mouseleave', () => close(), true);
break;
case 'focus-blur':
triggerNode.addEventListener('focus', toggle, true);
triggerNode.addEventListener('blur', () => close(), true);
break;
case 'focus-click':
triggerNode.addEventListener('focus', open, true);
window.addEventListener('click', onWindowClick, true);
break;
default:
throw new Error(`Event value of '${args.event}' is not supported. ${documentationLink}`);
}
window.addEventListener('keydown', onWindowKeyDown, true);
// Render popup on initialization
render();
// Lifecycle
return {
update(newArgs) {
close(() => {
args = newArgs;
render();
setDomElements();
});
},
destroy() {
// Trigger Events
triggerNode.removeEventListener('click', toggle, true);
triggerNode.removeEventListener('mouseover', open, true);
triggerNode.removeEventListener('mouseleave', () => close(), true);
triggerNode.removeEventListener('focus', toggle, true);
triggerNode.removeEventListener('focus', open, true);
triggerNode.removeEventListener('blur', () => close(), true);
// Window Events
window.removeEventListener('click', onWindowClick, true);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
};
}