238 lines
9.7 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
}
|