@mui/material
Version:
Material UI is an open-source React component library that implements Google's Material Design. It's comprehensive and can be used in production out of the box.
468 lines (445 loc) • 13.9 kB
JavaScript
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import useOnMount from '@mui/utils/useOnMount';
import useTimeout from '@mui/utils/useTimeout';
import { keyframes, css, styled, useTheme } from "../zero-styled/index.mjs";
import { useDefaultProps } from "../DefaultPropsProvider/index.mjs";
import Ripple from "./Ripple.mjs";
import touchRippleClasses from "./touchRippleClasses.mjs";
import useEventCallback from "../utils/useEventCallback.mjs";
import useReducedMotion from "../transitions/useReducedMotion.mjs";
import { jsx as _jsx } from "react/jsx-runtime";
const DURATION = 550;
export const DELAY_RIPPLE = 80;
const EMPTY_OBJ = {};
const EMPTY_ARRAY = [];
const NOOP = () => {};
/**
* Keep the same DOM order TouchRipple had when it used react-transition-group:
* exiting ripples stay in place, and new ripples are inserted before the final
* group of ripples that are waiting for their exit animation to finish.
*
* @param {number[]} prevOrder The previous DOM order, including ripples that may be exiting.
* @param {number[]} nextActiveKeys The ripples that should still be treated as active.
* @returns {number[]} The next DOM order, preserving the position of exiting ripples where possible.
*/
function mergeRippleOrder(prevOrder, nextActiveKeys) {
const nextKeySet = new Set(nextActiveKeys);
const nextKeysPending = new Map();
let pendingKeys = [];
for (const prevKey of prevOrder) {
if (nextKeySet.has(prevKey)) {
if (pendingKeys.length > 0) {
nextKeysPending.set(prevKey, pendingKeys);
pendingKeys = [];
}
} else {
pendingKeys.push(prevKey);
}
}
const nextOrder = [];
for (const nextKey of nextActiveKeys) {
const pendingBefore = nextKeysPending.get(nextKey);
if (pendingBefore) {
nextOrder.push(...pendingBefore);
}
nextOrder.push(nextKey);
}
nextOrder.push(...pendingKeys);
return nextOrder;
}
/**
* Calculate where the ripple should start and how large it must be to cover the host element.
*
* @param {object} params
* @param {object} params.event The mouse or touch event that started the ripple.
* @param {HTMLElement | null} params.element The host element used for measurements. Tests pass `null`.
* @param {boolean} params.center If `true`, start the ripple from the center of the host element.
* @returns {{ rippleX: number, rippleY: number, rippleSize: number }} The ripple position and size.
*/
function computeRippleState({
event,
element,
center
}) {
const rect = element ? element.getBoundingClientRect() : {
width: 0,
height: 0,
left: 0,
top: 0
};
let rippleX;
let rippleY;
if (center || event === undefined || event.clientX === 0 && event.clientY === 0 || !event.clientX && !event.touches) {
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);
}
let rippleSize;
if (center) {
rippleSize = Math.sqrt((2 * rect.width ** 2 + rect.height ** 2) / 3);
// Mobile Chrome can skip this animation for even pixel sizes.
if (rippleSize % 2 === 0) {
rippleSize += 1;
}
} else {
const sizeX = Math.max(Math.abs((element ? element.clientWidth : 0) - rippleX), rippleX) * 2 + 2;
const sizeY = Math.max(Math.abs((element ? element.clientHeight : 0) - rippleY), rippleY) * 2 + 2;
rippleSize = Math.sqrt(sizeX ** 2 + sizeY ** 2);
}
return {
rippleX,
rippleY,
rippleSize
};
}
const enterKeyframe = keyframes`
0% {
transform: scale(0);
opacity: 0.1;
}
100% {
transform: scale(1);
opacity: 0.3;
}
`;
const exitKeyframe = keyframes`
0% {
opacity: 1;
}
100% {
opacity: 0;
}
`;
const pulsateKeyframe = keyframes`
0% {
transform: scale(1);
}
50% {
transform: scale(0.92);
}
100% {
transform: scale(1);
}
`;
function getAnimationStyles(theme) {
if (theme.motion.reducedMotion === 'always') {
return null;
}
const styles = css`
&.${touchRippleClasses.rippleVisible} {
animation-name: ${enterKeyframe};
animation-duration: ${DURATION}ms;
animation-timing-function: ${theme.transitions.easing.easeInOut};
}
&.${touchRippleClasses.ripplePulsate} {
animation-duration: ${theme.transitions.duration.shorter}ms;
}
& .${touchRippleClasses.childLeaving} {
animation-name: ${exitKeyframe};
animation-duration: ${DURATION}ms;
animation-timing-function: ${theme.transitions.easing.easeInOut};
}
& .${touchRippleClasses.childPulsate} {
animation-name: ${pulsateKeyframe};
animation-duration: 2500ms;
animation-timing-function: ${theme.transitions.easing.easeInOut};
animation-iteration-count: infinite;
animation-delay: 200ms;
}
`;
if (theme.motion.reducedMotion === 'system') {
return css`
@media (prefers-reduced-motion: no-preference) {
${styles}
}
`;
}
return styles;
}
export const TouchRippleRoot = styled('span', {
name: 'MuiTouchRipple',
slot: 'Root'
})({
overflow: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: 'inherit'
});
// This `styled()` call uses keyframes. styled-components only supports keyframes
// in template strings, so do not convert these styles to a JS object.
export const TouchRippleRipple = styled(Ripple, {
name: 'MuiTouchRipple',
slot: 'Ripple'
})`
opacity: 0;
position: absolute;
&.${touchRippleClasses.rippleVisible} {
opacity: 0.3;
transform: scale(1);
}
/*
* Order matters: 'child', 'childLeaving' and 'childPulsate' apply to the same
* element with equal specificity, so the later rule wins. 'child' must come
* before 'childLeaving' so the leaving 'opacity: 0' takes precedence. A focus
* (pulsate) ripple keeps 'pulsateKeyframe' (no opacity animation) on exit, so
* it relies on this static 'opacity: 0' to disappear on blur instead of
* lingering until removal.
*/
& .${touchRippleClasses.child} {
opacity: 1;
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: currentColor;
}
& .${touchRippleClasses.childLeaving} {
opacity: 0;
}
& .${touchRippleClasses.childPulsate} {
position: absolute;
/* @noflip */
left: 0px;
top: 0;
}
${({
theme
}) => getAnimationStyles(theme)}
`;
/**
* @ignore - internal component.
*/
const TouchRipple = /*#__PURE__*/React.forwardRef(function TouchRipple(inProps, ref) {
const props = useDefaultProps({
props: inProps,
name: 'MuiTouchRipple'
});
const theme = useTheme();
const reducedMotion = useReducedMotion(theme.motion.reducedMotion, false);
const {
center: centerProp = false,
classes = EMPTY_OBJ,
className,
...other
} = props;
// Store ripples as data so we can keep exiting ripples mounted until their
// exit animation ends. Ripple calls onExited when it is safe to remove one.
const [rippleState, setRippleState] = React.useState({
items: EMPTY_ARRAY,
order: EMPTY_ARRAY
});
const ripples = rippleState.items;
const nextKey = React.useRef(0);
const rippleCallback = React.useRef(null);
const mountedRef = React.useRef(false);
useOnMount(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
});
React.useEffect(() => {
if (rippleCallback.current) {
rippleCallback.current();
rippleCallback.current = null;
}
}, [ripples]);
// Used to filter out mouse emulated events on mobile.
const ignoringMouseDown = React.useRef(false);
// We use a timer in order to only show the ripples for touch "click" like events.
// We don't want to display the ripple for touch scroll events.
const startTimer = useTimeout();
// Holds delayed touch-start work until the delay expires or touchend forces it to run.
const startTimerCommit = React.useRef(null);
const container = React.useRef(null);
const handleExited = useEventCallback(key => {
if (!mountedRef.current) {
return;
}
setRippleState(prevState => {
const nextItems = prevState.items.filter(ripple => ripple.key !== key);
const nextOrder = mergeRippleOrder(prevState.order.filter(rippleKey => rippleKey !== key), nextItems.filter(ripple => !ripple.exiting).map(ripple => ripple.key));
return {
items: nextItems,
order: nextOrder
};
});
});
const startCommit = useEventCallback(params => {
const {
pulsate,
rippleX,
rippleY,
rippleSize,
cb
} = params;
const key = nextKey.current;
nextKey.current += 1;
setRippleState(prevState => {
const nextItems = [...prevState.items, {
key,
pulsate,
rippleX,
rippleY,
rippleSize,
exiting: false
}];
return {
items: nextItems,
order: mergeRippleOrder(prevState.order, nextItems.filter(ripple => !ripple.exiting).map(ripple => ripple.key))
};
});
rippleCallback.current = cb;
});
const start = useEventCallback((event = EMPTY_OBJ, options = EMPTY_OBJ, cb = NOOP) => {
const {
pulsate = false,
center = centerProp || options.pulsate,
fakeElement = false // Used only by tests.
} = options;
if (event?.type === 'mousedown' && ignoringMouseDown.current) {
ignoringMouseDown.current = false;
return;
}
if (event?.type === 'touchstart') {
ignoringMouseDown.current = true;
}
const element = fakeElement ? null : container.current;
const {
rippleX,
rippleY,
rippleSize
} = computeRippleState({
event,
element,
center
});
// Delay touch ripples so scroll gestures do not flash a ripple.
if (event?.touches) {
// Ignore extra touchstart events from multi-touch. There is only one
// delayed start callback to clear on unmount.
if (startTimerCommit.current === null) {
startTimerCommit.current = () => {
startCommit({
pulsate,
rippleX,
rippleY,
rippleSize,
cb
});
};
startTimer.start(DELAY_RIPPLE, () => {
if (startTimerCommit.current) {
startTimerCommit.current();
startTimerCommit.current = null;
}
});
}
} else {
startCommit({
pulsate,
rippleX,
rippleY,
rippleSize,
cb
});
}
});
const pulsate = useEventCallback(() => {
start(EMPTY_OBJ, {
pulsate: true
});
});
const stop = useEventCallback((event, cb) => {
startTimer.clear();
// If touch ends before the delay finishes, show the ripple now and stop it
// on the next tick so the user still gets feedback.
if (event?.type === 'touchend' && startTimerCommit.current) {
startTimerCommit.current();
startTimerCommit.current = null;
startTimer.start(0, () => {
stop(event, cb);
});
return;
}
startTimerCommit.current = null;
setRippleState(prevState => {
const firstActiveIndex = prevState.items.findIndex(ripple => !ripple.exiting);
if (firstActiveIndex === -1) {
return prevState;
}
const nextItems = prevState.items.slice();
nextItems[firstActiveIndex] = {
...nextItems[firstActiveIndex],
exiting: true
};
return {
items: nextItems,
order: mergeRippleOrder(prevState.order, nextItems.filter(ripple => !ripple.exiting).map(ripple => ripple.key))
};
});
rippleCallback.current = cb;
});
React.useImperativeHandle(ref, () => ({
pulsate,
start,
stop
}), [pulsate, start, stop]);
const rippleByKey = new Map(ripples.map(ripple => [ripple.key, ripple]));
const orderedRipples = rippleState.order.map(rippleKey => rippleByKey.get(rippleKey)).filter(Boolean);
// Keep the old react-transition-group DOM order:
// exiting ripples stay in place, and new ripples are inserted before the
// final group waiting for its exit animation to finish.
return /*#__PURE__*/_jsx(TouchRippleRoot, {
className: clsx(touchRippleClasses.root, classes.root, className),
ref: container,
...other,
children: orderedRipples.map(ripple => /*#__PURE__*/_jsx(TouchRippleRipple, {
classes: {
ripple: clsx(classes.ripple, touchRippleClasses.ripple),
rippleVisible: clsx(classes.rippleVisible, touchRippleClasses.rippleVisible),
ripplePulsate: clsx(classes.ripplePulsate, touchRippleClasses.ripplePulsate),
child: clsx(classes.child, touchRippleClasses.child),
childLeaving: clsx(classes.childLeaving, touchRippleClasses.childLeaving),
childPulsate: clsx(classes.childPulsate, touchRippleClasses.childPulsate)
},
timeout: reducedMotion.shouldReduceMotion ? 0 : DURATION,
pulsate: ripple.pulsate,
rippleX: ripple.rippleX,
rippleY: ripple.rippleY,
rippleSize: ripple.rippleSize,
in: !ripple.exiting,
onExited: () => handleExited(ripple.key)
}, ripple.key))
});
});
process.env.NODE_ENV !== "production" ? TouchRipple.propTypes /* remove-proptypes */ = {
/**
* If `true`, the ripple starts at the center of the component
* rather than at the point of interaction.
*/
center: PropTypes.bool,
/**
* Override or extend the styles applied to the component.
*/
classes: PropTypes.object,
/**
* @ignore
*/
className: PropTypes.string
} : void 0;
export default TouchRipple;