UNPKG

@wilkr/alpine-ripple

Version:

Ripple effect (materialize) for Alpine.js.

450 lines (428 loc) 15 kB
(function(f){typeof define==='function'&&define.amd?define(f):f();})((function(){'use strict';/** * Get an attribute from an element that starts with a string. * * @param {HTMLElement} el * @param {string} startsWith * @returns {object|null} */ const getAttributeThatStartsWith = (el, startsWith) => { for (const attr of el.attributes) { if (attr.name.startsWith(startsWith)) { return attr; } } return null; };/** * Convert a space separated class string to a valid css selector. * * @param {string} configClass * @returns {string} */ const configClassToSelector = configClass => `.${configClass.replace(' ', '.')}`; /** * Add a space separated class string to an element. * * @param {HTMLElement} el * @param {string} configClass */ const addConfigClassToElement = (el, configClass) => configClass.split(' ').forEach(c => el.classList.add(c)); /** * Remove a space separated class string from an element. * * @param {HTMLElement} el * @param {string} configClass */ const removeConfigClassFromElement = (el, configClass) => configClass.split(' ').forEach(c => el.classList.remove(c));/** * Check if the keydown event is the enter key or the space bar. * * @param {KeyboardEvent} event * @returns {boolean} */ const isEnterOrSpace = event => event.key === 'Enter' || event.key === ' ';/** * Return a user defined custom color for the ripple effect. * * @param {Array} modifiers * @returns {string} */ const getCustomColorFromModifiers = modifiers => { if (!modifiers.includes('color')) { return ''; } const nextModifier = modifiers[modifiers.indexOf('color') + 1] || 'invalid-color'; if (nextModifier.indexOf('#') === 0 || nextModifier.indexOf('rgb') === 0) { return nextModifier; } return nextModifier.indexOf('bg') === 0 ? nextModifier : `bg-${nextModifier}`; }; /** * Return a user defined custom radius for the ripple effect. * * @param {Array} modifiers * @returns {string} */ const getCustomRadiusFromModifiers = modifiers => { if (!modifiers.includes('radius')) { return ''; } let nextModifier = modifiers[modifiers.indexOf('radius') + 1] || 'invalid-radius'; // _ allows us to use decimals, such as 0.5. nextModifier = nextModifier.replace('_', '.'); // Separate the numeric value from the unit in nextModifier. // Possible values for nextModifier: 50, 50.5, 50.5px, 50px, 50%, 50rem, 50em const numericValue = nextModifier.match(/^[0-9]+(\.[0-9]+)?/)[0]; let unit = nextModifier.replace(numericValue, ''); if (!unit) { unit = '%'; } return `${numericValue}${unit}`; };const startRipple = (event, el, options = {}) => { const { center = options.pulsate } = options; const rect = el.getBoundingClientRect(); // Determine the size of the ripple. let rippleX, rippleY, rippleSize; if (center || event === undefined || event.clientX === 0 && event.clientY === 0 || !event.clientX && !event.touches) { // This is mostly used for the keyboard focus ripple. rippleX = Math.round(rect.width / 2); rippleY = Math.round(rect.height / 2); } else { const { clientX, clientY } = event.touches && event.touches.length > 0 ? event.touches[0] : event; rippleX = Math.round(clientX - rect.left); rippleY = Math.round(clientY - rect.top); } if (center) { rippleSize = Math.sqrt((2 * rect.width ** 2 + rect.height ** 2) / 3); // For some reason the animation is broken on Mobile Chrome if the size is even. if (rippleSize % 2 === 0) { rippleSize++; } } else { const sizeX = Math.max(Math.abs((el ? el.clientWidth : 0) - rippleX), rippleX) * 2 + 2, sizeY = Math.max(Math.abs((el ? el.clientHeight : 0) - rippleY), rippleY) * 2 + 2; rippleSize = Math.sqrt(sizeX ** 2 + sizeY ** 2); } return { width: `${rippleSize}px`, height: `${rippleSize}px`, top: `${-(rippleSize / 2) + rippleY}px`, left: `${-(rippleSize / 2) + rippleX}px` }; };/** * Convert an object of style properties to a string of CSS. * * @param {object} styles * @returns {string} */ var toStyles = (styles => Object.entries(styles).map(([key, value]) => `${formatStyleKey(key)}: ${value}`).join(';')); /** * convert a style key to a css property. * * @param {string} key * @returns {string} */ const formatStyleKey = key => key.replace(/([A-Z])/g, '-$1').toLowerCase();/** * Some events, such as a right click or ctrl + left click won't trigger a mouseup event, * so we need to prevent the ripple from being added in those cases. * * @param {MouseEvent} event * @returns {boolean} */ var willHaveAMouseUpEvent = (event => { if (event.ctrlKey) { return false; } return event.button === 0 || event.button === 1; });/** * Determine if the element is being focused from a click or tab. * This isn't the best way to do this, but it works for now. * * @param {HTMLElement} el * @returns {boolean} */ const isFromTab = el => !el.hasAttribute('data-ripple-click'); /** * Configuration options for the ripple focus directive. * * @type {{focusClass: string, focusedClass: string}} */ let config$1 = { focusClass: 'ripple-focus', focusedClass: 'ripple-focus-active' }; /** * Add a pulsating ripple effect to the element when it is focused. * * @param {Event} event * @param {HTMLElement} el * @param {array} modifiers */ const addRippleFocus = (event, el, modifiers = []) => { if (!isFromTab(el)) { event.preventDefault(); return; } const rippleStyles = startRipple(event, el, { pulsate: true }); const rippleFocus = document.createElement('span'); addConfigClassToElement(rippleFocus, config$1.focusClass); el.appendChild(rippleFocus); const rippleFocusChild = document.createElement('span'); rippleFocusChild.classList.add('ripple-focus-child'); const color = getCustomColorFromModifiers(modifiers); if (color.indexOf('bg-') === 0) { // Prefix with '!' for !important (requires Tailwind). rippleFocusChild.classList.add(`!${color}`); } else if (color.indexOf('#') === 0 || color.indexOf('rgb') === 0) { rippleStyles['--ripple-focus-color'] = color; } rippleFocusChild.setAttribute('style', toStyles(rippleStyles)); rippleFocusChild.appendChild(document.createElement('span')); rippleFocus.appendChild(rippleFocusChild); addConfigClassToElement(el, config$1.focusedClass); }; /** * Remove the ripple focus effect from the element. * * @param {HTMLElement} el */ const removeRippleFocus = el => { el.hasAttribute('data-ripple-click') && el.removeAttribute('data-ripple-click'); try { removeConfigClassFromElement(el, config$1.focusedClass); } catch (e) {} const ripple = el.querySelector(configClassToSelector(config$1.focusClass)); ripple && ripple.remove(); }; /** * Add a data attribute to the element so we know it was clicked instead of keyboard focused. * * @param {HTMLElement} el */ const rippleFocusClick = el => { el.setAttribute('data-ripple-click', 'true'); }; /** * Remove our click data attribute from the element. * * @param {HTMLElement} el * @returns {false|void} */ const rippleFocusMouseUp = el => el.hasAttribute('data-ripple-click') && el.removeAttribute('data-ripple-click'); var rippleFocus = ((Alpine, rippleConfig) => { config$1 = { ...config$1, ...rippleConfig }; Alpine.directive('ripple-focus', (el, { modifiers, expression }, { cleanup }) => { const clickHandler = () => rippleFocusClick(el); const mouseUpHandler = () => rippleFocusMouseUp(el); const focusHandler = event => addRippleFocus(event, el, modifiers); const blurHandler = () => removeRippleFocus(el); /* * We need a way to know if we are giving focus to the element by clicking or tabbing. * Since there isn't a reliable way to do this with the focus event and the `mousedown` * event is fired before the `focus` event, we will set a data attribute on the element * that we can check for in the `focus` event. * * This way, we don't show our pulsating ripple animation when the user clicks on the * element, but only show it when the user tabs to the element. */ el.addEventListener('mousedown', clickHandler); el.addEventListener('mouseup', mouseUpHandler); el.addEventListener('contextmenu', clickHandler); el.addEventListener('focus', focusHandler); el.addEventListener('blur', blurHandler); cleanup(() => { el.removeEventListener('mousedown', clickHandler); el.removeEventListener('mouseup', mouseUpHandler); el.removeEventListener('contextmenu', clickHandler); el.removeEventListener('focus', focusHandler); el.removeEventListener('blur', blurHandler); }); }); });/** * Configuration options for the ripple click directive. * * @type {{removeTimeout: number, rippleClass: string}} */ let config = { rippleClass: 'ripple', removeTimeout: 1000, focusedClass: 'ripple-focus-active' // So we can check if the element is focused by our directive. }; /** * Check if the element has the focused class. * * @param {HTMLElement} el * @returns {boolean} */ const hasRippleFocus = el => config.focusedClass.split(' ').every(className => el.classList.contains(className)); /** * Add a ripple effect to the element. * * @param {MouseEvent|KeyboardEvent} event * @param {HTMLElement} el * @param {Array} modifiers */ const addRipple = (event, el, modifiers = []) => { if (!willHaveAMouseUpEvent(event) && !isEnterOrSpace(event)) { return; } const styles = startRipple(event, el); const ripple = document.createElement('span'); addConfigClassToElement(ripple, config.rippleClass); el.appendChild(ripple); const innerRipple = document.createElement('span'); const color = getCustomColorFromModifiers(modifiers); if (color.indexOf('bg-') === 0) { // Prefix with '!' for !important (requires Tailwind). innerRipple.classList.add(`!${color}`); } else if (color.indexOf('#') === 0 || color.indexOf('rgb') === 0) { styles['--ripple-color'] = color; } const radius = getCustomRadiusFromModifiers(modifiers); if (radius) { styles['--ripple-radius'] = radius; } ripple.appendChild(innerRipple); innerRipple.setAttribute('style', toStyles(styles)); }; /** * Remove the ripple effect from the element. * * @param {HTMLElement} el * @param {boolean} alsoRemoveFocus */ const removeRipple = (el, alsoRemoveFocus = false) => { alsoRemoveFocus && removeRippleFocus(el); setTimeout(() => { // We are only removing the first instance to prevent ripples from subsequent clicks // being removed too quickly before the ripple effect can properly be seen. const ripple = el.querySelector(configClassToSelector(config.rippleClass)); ripple && ripple.remove(); }, config.removeTimeout); }; /** * Show a ripple effect when the user presses the enter or space key. * * @param {KeyboardEvent} event * @param {HTMLElement} el * @param {array} modifiers */ const handleRippleKeydown = (event, el, modifiers) => { if (!isEnterOrSpace(event)) { return; } const originallyHadFocus = hasRippleFocus(el); addRipple(event, el, modifiers); removeRippleFocus(el); if (originallyHadFocus) { addFocusBack(event, el); } }; /** * Add a focus ripple effect back to the element after the ripple effect has been removed. * * @param {Event} event * @param {HTMLElement} el */ const addFocusBack = (event, el) => { const hasRippleClick = el.contains(el.querySelector(configClassToSelector(config.rippleClass))); if (hasRippleClick) { setTimeout(() => addFocusBack(event, el), config.removeTimeout); return; } // We need to get the modifiers, if any, from the x-ripple-focus directive. const directive = getAttributeThatStartsWith(el, 'x-ripple-focus'); const focusModifiers = directive ? directive.name.split('.').slice(1) : []; addRippleFocus(event, el, focusModifiers); }; /** * Define an Alpine directive that adds a ripple click effect to a given element. * * @param {Object} Alpine * @param {Object} rippleConfig */ var rippleClick = ((Alpine, rippleConfig) => { config = { ...config, ...rippleConfig }; Alpine.directive('ripple', (el, { modifiers, expression }, { cleanup }) => { const clickHandler = event => addRipple(event, el, modifiers); const mouseUpHandler = () => removeRipple(el, true); const keydownHandler = event => handleRippleKeydown(event, el, modifiers); const keyupHandler = event => isEnterOrSpace(event) && removeRipple(el, false); const passiveOptions = { passive: true }; el.addEventListener('mousedown', clickHandler); el.addEventListener('mouseup', mouseUpHandler); el.addEventListener('mouseleave', mouseUpHandler); el.addEventListener('contextmenu', mouseUpHandler); el.addEventListener('touchstart', clickHandler, passiveOptions); el.addEventListener('touchend', mouseUpHandler); el.addEventListener('touchmove', mouseUpHandler, passiveOptions); el.addEventListener('dragleave', mouseUpHandler); el.addEventListener('keydown', keydownHandler); el.addEventListener('keyup', keyupHandler); cleanup(() => { el.removeEventListener('mousedown', clickHandler); el.removeEventListener('mouseup', mouseUpHandler); el.removeEventListener('mouseleave', mouseUpHandler); el.removeEventListener('contextmenu', mouseUpHandler); el.removeEventListener('touchstart', clickHandler, passiveOptions); el.removeEventListener('touchend', mouseUpHandler); el.removeEventListener('touchmove', mouseUpHandler, passiveOptions); el.removeEventListener('dragleave', mouseUpHandler); el.removeEventListener('keydown', keydownHandler); el.removeEventListener('keyup', keyupHandler); }); }); });/** * Configuration options for the ripple directives. * * @type {{rippleClass: string, removeTimeout: number, focusClass: string, focusedClass: string}} */ const rippleConfig = { rippleClass: 'ripple', removeTimeout: 1000, focusClass: 'ripple-focus', focusedClass: 'ripple-focus-active' }; function Ripple(Alpine) { rippleClick(Alpine, rippleConfig); rippleFocus(Alpine, rippleConfig); } Ripple.configure = config => { if (config.hasOwnProperty('class') && typeof config.class === 'string') { rippleConfig.rippleClass = config.class; } if (config.hasOwnProperty('removeTimeout') && typeof config.removeTimeout === 'number') { rippleConfig.removeTimeout = config.removeTimeout; } if (config.hasOwnProperty('focusClass') && typeof config.focusClass === 'string') { rippleConfig.focusClass = config.focusClass; } if (config.hasOwnProperty('focusedClass') && typeof config.focusedClass === 'string') { rippleConfig.focusedClass = config.focusedClass; } return Ripple; };document.addEventListener('alpine:initializing', () => { Ripple(window.Alpine); });}));//# sourceMappingURL=alpine-ripple.js.map