ripplet.js
Version:
Fully controllable vanilla-js material design ripple effect generator
185 lines (165 loc) • 8.32 kB
text/typescript
export type RippletOptions = Partial<typeof defaultOptions>
export type RippletContainerElement = HTMLElement & { readonly __ripplet__: unique symbol }
export const defaultOptions = {
className: '',
color: 'currentcolor' as string | null,
opacity: 0.1 as number | null,
spreadingDuration: '.4s' as string | null,
spreadingDelay: '0s' as string | null,
spreadingTimingFunction: 'linear' as string | null,
clearing: true as boolean | 'true' | 'false' | null,
clearingDuration: '1s' as string | null,
clearingDelay: '0s' as string | null,
clearingTimingFunction: 'ease-in-out' as string | null,
centered: false as boolean | 'true' | 'false' | null,
appendTo: 'auto' as 'auto' | 'target' | 'parent' | 'body' | string | null,
}
const target2container2ripplet = new Map<Element, Map<RippletContainerElement, HTMLElement>>()
let containerContainerTemplate: HTMLElement
const findElementAppendTo = (target: Element, appendTo: string | null): Element => {
if (appendTo && appendTo !== 'auto') {
return appendTo === 'target' ? target : appendTo === 'parent' ? target.parentElement! : document.querySelector(appendTo)!
}
while (
target &&
(target instanceof SVGElement ||
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLImageElement ||
target instanceof HTMLHRElement)
) {
target = target.parentElement!
}
return target
}
function ripplet(
targetSuchAsPointerEvent: MouseEvent | Readonly<{ currentTarget: Element; clientX: number; clientY: number }>,
options?: Readonly<RippletOptions>,
): RippletContainerElement
function ripplet(
targetSuchAsPointerEvent: Event | Readonly<{ currentTarget: Element }>,
options?: Readonly<RippletOptions>,
): RippletContainerElement | undefined
function ripplet(
{ currentTarget, clientX, clientY }: Readonly<{ currentTarget: any; clientX?: number; clientY?: number }>,
_options?: Readonly<RippletOptions>,
): RippletContainerElement | undefined {
if (!(currentTarget instanceof Element)) {
return
}
const options: typeof defaultOptions = _options
? (Object.keys(defaultOptions) as (keyof RippletOptions)[]).reduce<any>(
(merged, field) => ((merged[field] = _options.hasOwnProperty(field) ? _options[field] : defaultOptions[field]), merged),
{},
)
: defaultOptions
if (!containerContainerTemplate) {
const _containerContainerTemplate = document.createElement('div')
_containerContainerTemplate.innerHTML =
'<div style="float:left;position:relative;isolation:isolate;pointer-events:none"><div style="position:absolute;overflow:hidden;transform-origin:0 0"><div style="border-radius:50%;transform:scale(0)"></div></div></div>'
containerContainerTemplate = _containerContainerTemplate.firstChild as HTMLElement
}
const targetRect = currentTarget.getBoundingClientRect()
if (options.centered && options.centered !== 'false') {
clientX = targetRect.left + targetRect.width * 0.5
clientY = targetRect.top + targetRect.height * 0.5
} else if (typeof clientX !== 'number' || typeof clientY !== 'number') {
return
} else {
const zoomReciprocal = 1 / (+getComputedStyle(document.body).zoom || 1)
clientX = clientX * zoomReciprocal
clientY = clientY * zoomReciprocal
}
const targetStyle = getComputedStyle(currentTarget)
const applyCssVariable = (value: string | null | undefined) => {
const match = value && /^var\((--.+)\)$/.exec(value)
return match ? targetStyle.getPropertyValue(match[1]!) : value
}
const elementAppendTo = findElementAppendTo(currentTarget, options.appendTo)
const containerContainerElement: RippletContainerElement = elementAppendTo.appendChild(containerContainerTemplate.cloneNode(true)) as any
containerContainerElement.style.zIndex = ((+targetStyle.zIndex || 0) + 1) as string & number
const containerElement = containerContainerElement.firstChild as HTMLElement
{
let containerRect = containerElement.getBoundingClientRect()
const containerStyle = containerElement.style
containerStyle.top = `${targetRect.top - containerRect.top}px`
containerStyle.left = `${targetRect.left - containerRect.left}px`
containerStyle.width = `${targetRect.width}px`
containerStyle.height = `${targetRect.height}px`
containerStyle.opacity = applyCssVariable(options.opacity as string & number)!
containerStyle.borderTopLeftRadius = targetStyle.borderTopLeftRadius
containerStyle.borderTopRightRadius = targetStyle.borderTopRightRadius
containerStyle.borderBottomLeftRadius = targetStyle.borderBottomLeftRadius
containerStyle.borderBottomRightRadius = targetStyle.borderBottomRightRadius
containerStyle.clipPath = targetStyle.clipPath
containerRect = containerElement.getBoundingClientRect()
const scaleX = targetRect.width / containerRect.width
const scaleY = targetRect.height / containerRect.height
containerStyle.transform = `scale(${scaleX},${scaleY}) translate(${targetRect.left - containerRect.left}px,${
targetRect.top - containerRect.top
}px)`
}
{
const distanceX = Math.max(clientX - targetRect.left, targetRect.right - clientX)
const distanceY = Math.max(clientY - targetRect.top, targetRect.bottom - clientY)
const radius = Math.sqrt(distanceX * distanceX + distanceY * distanceY)
const rippletElement = containerElement.firstChild as HTMLElement
const rippletStyle = rippletElement.style
const color = applyCssVariable(options.color)!
rippletStyle.backgroundColor = /^currentcolor$/i.test(color) ? targetStyle.color : color
rippletElement.className = options.className
rippletStyle.width = rippletStyle.height = `${radius + radius}px`
if (getComputedStyle(elementAppendTo).direction === 'rtl') {
rippletStyle.marginRight = `${targetRect.right - clientX - radius}px`
} else {
rippletStyle.marginLeft = `${clientX - targetRect.left - radius}px`
}
rippletStyle.marginTop = `${clientY - targetRect.top - radius}px`
rippletStyle.transition = `transform ${applyCssVariable(options.spreadingDuration)} ${applyCssVariable(
options.spreadingTimingFunction,
)} ${applyCssVariable(options.spreadingDelay)},opacity ${applyCssVariable(options.clearingDuration)} ${applyCssVariable(
options.clearingTimingFunction,
)} ${applyCssVariable(options.clearingDelay)}`
rippletElement.addEventListener('transitionend', event => {
if (event.propertyName === 'opacity' && containerContainerElement.parentElement) {
containerContainerElement.parentElement.removeChild(containerContainerElement)
}
})
if (options.clearing && options.clearing !== 'false') {
rippletStyle.opacity = '0'
} else {
let container2ripplet = target2container2ripplet.get(currentTarget)
if (!container2ripplet) {
target2container2ripplet.set(currentTarget, (container2ripplet = new Map<RippletContainerElement, HTMLElement>()))
}
container2ripplet.set(containerContainerElement, rippletElement)
}
// reflect styles by force layout and start transition
rippletElement.offsetWidth // tslint:disable-line:no-unused-expression
rippletStyle.transform = ''
}
return containerContainerElement
}
ripplet.clear = (targetElement?: Element, rippletContainerElement?: RippletContainerElement) => {
if (targetElement) {
const container2ripplet = target2container2ripplet.get(targetElement)
if (container2ripplet) {
if (rippletContainerElement) {
const rippletElement = container2ripplet.get(rippletContainerElement)
rippletElement && (rippletElement.style.opacity = '0')
container2ripplet.delete(rippletContainerElement)
container2ripplet.size === 0 && target2container2ripplet.delete(targetElement)
} else {
container2ripplet.forEach(r => (r.style.opacity = '0'))
target2container2ripplet.delete(targetElement)
}
}
} else {
target2container2ripplet.forEach(container2ripplet => container2ripplet.forEach(r => (r.style.opacity = '0')))
target2container2ripplet.clear()
}
}
ripplet.defaultOptions = defaultOptions
ripplet._ripplets = target2container2ripplet
export default ripplet