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); } }; }