Unverified Commit 86ff1955 authored by BM's avatar BM Committed by GitHub
Browse files

fix(Carousel): support for ref attribute (#4917)



* fix(Carousel): support for ref attribute

* fix(Carousel): revisions to ref attribute

* fix(Carousel): add imperative logic to expose refs

* refactor: Revise implementation to use hooks more

* simplify

* fix callbacks

* Apply suggestions from code review

Co-authored-by: default avatarmuzakparov <b.muzakparov@newagesol.com>
Co-authored-by: default avatarJimmy Jia <tesrin@gmail.com>
parent fac2160c
import useEventCallback from '@restart/hooks/useEventCallback';
import useUpdateEffect from '@restart/hooks/useUpdateEffect';
import useTimeout from '@restart/hooks/useTimeout';
import classNames from 'classnames';
import styles from 'dom-helpers/css';
import transitionEnd from 'dom-helpers/transitionEnd';
import Transition from 'react-transition-group/Transition';
import PropTypes from 'prop-types';
import React, { cloneElement } from 'react';
import { uncontrollable } from 'uncontrollable';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useImperativeHandle,
} from 'react';
import { useUncontrolled } from 'uncontrollable';
import CarouselCaption from './CarouselCaption';
import CarouselItem from './CarouselItem';
import { forEach, map } from './ElementChildren';
import { map } from './ElementChildren';
import SafeAnchor from './SafeAnchor';
import { createBootstrapComponent } from './ThemeProvider';
import { useBootstrapPrefix } from './ThemeProvider';
import triggerBrowserReflow from './triggerBrowserReflow';
const countChildren = c =>
React.Children.toArray(c).filter(React.isValidElement).length;
const SWIPE_THRESHOLD = 40;
// TODO: `slide` should be `animate`.
const propTypes = {
/**
* @default 'carousel'
......@@ -33,8 +38,10 @@ const propTypes = {
/** Cross fade slides instead of the default slide animation */
fade: PropTypes.bool,
/** Slides will loop to the start when the last one transitions */
wrap: PropTypes.bool,
/**
* Show the Carousel previous and next arrows for changing the current slide
*/
controls: PropTypes.bool,
/**
* Show a set of slide position indicators
......@@ -42,44 +49,61 @@ const propTypes = {
indicators: PropTypes.bool,
/**
* The amount of time to delay between automatically cycling an item.
* If `null`, carousel will not automatically cycle.
* Controls the current visible slide
*
* @controllable onSelect
*/
interval: PropTypes.number,
activeIndex: PropTypes.number,
/**
* Show the Carousel previous and next arrows for changing the current slide
* Callback fired when the active item changes.
*
* ```js
* (eventKey: number, event: Object | null) => void
* ```
*
* @controllable activeIndex
*/
controls: PropTypes.bool,
onSelect: PropTypes.func,
/**
* Temporarily pause the slide interval when the mouse hovers over a slide.
* Callback fired when a slide transition starts.
*
* ```js
* (eventKey: number, direction: 'left' | 'right') => void
*/
pauseOnHover: PropTypes.bool,
/** Enable keyboard navigation via the Arrow keys for changing slides */
keyboard: PropTypes.bool,
onSlide: PropTypes.func,
/**
* Callback fired when the active item changes.
* Callback fired when a slide transition ends.
*
* ```js
* (eventKey: any, direction: 'prev' | 'next', ?event: Object) => any
* ```
*
* @controllable activeIndex
* (eventKey: number, direction: 'left' | 'right') => void
*/
onSelect: PropTypes.func,
onSlid: PropTypes.func,
/** A callback fired after a slide transitions in */
onSlideEnd: PropTypes.func,
/**
* The amount of time to delay between automatically cycling an item. If `null`, carousel will not automatically cycle.
*/
interval: PropTypes.number,
/** Whether the carousel should react to keyboard events. */
keyboard: PropTypes.bool,
/**
* Controls the current visible slide
* If set to `"hover"`, pauses the cycling of the carousel on `mouseenter` and resumes the cycling of the carousel on `mouseleave`. If set to `false`, hovering over the carousel won't pause it.
*
* @controllable onSelect
* On touch-enabled devices, when set to `"hover"`, cycling will pause on `touchend` (once the user finished interacting with the carousel) for two intervals, before automatically resuming. Note that this is in addition to the above mouse behavior.
*/
activeIndex: PropTypes.number,
pause: PropTypes.oneOf(['hover', false]),
/** Whether the carousel should cycle continuously or have hard stops. */
wrap: PropTypes.bool,
/**
* Whether the carousel should support left/right swipe interactions on touchscreen devices.
*/
touch: PropTypes.bool,
/** Override the default button icon for the "previous" control */
prevIcon: PropTypes.node,
......@@ -100,441 +124,430 @@ const propTypes = {
* Set to null to deactivate.
*/
nextLabel: PropTypes.string,
/**
* Whether the carousel should support left/right swipe interactions on touchscreen devices.
*/
touch: PropTypes.bool,
};
const defaultProps = {
slide: true,
fade: false,
controls: true,
indicators: true,
defaultActiveIndex: 0,
interval: 5000,
keyboard: true,
pauseOnHover: true,
pause: 'hover',
wrap: true,
indicators: true,
controls: true,
activeIndex: 0,
touch: true,
prevIcon: <span aria-hidden="true" className="carousel-control-prev-icon" />,
prevLabel: 'Previous',
nextIcon: <span aria-hidden="true" className="carousel-control-next-icon" />,
nextLabel: 'Next',
touch: true,
};
class Carousel extends React.Component {
state = {
prevClasses: '',
currentClasses: 'active',
touchStartX: 0,
};
function isVisible(element) {
if (
!element ||
!element.style ||
!element.parentNode ||
!element.parentNode.style
) {
return false;
}
const elementStyle = getComputedStyle(element);
isUnmounted = false;
return (
elementStyle.display !== 'none' &&
elementStyle.visibility !== 'hidden' &&
getComputedStyle(element.parentNode).display !== 'none'
);
}
carousel = React.createRef();
const Carousel = React.forwardRef((uncontrolledProps, ref) => {
const {
// Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595
as: Component = 'div',
bsPrefix,
slide,
fade,
controls,
indicators,
activeIndex,
onSelect,
onSlide,
onSlid,
interval,
keyboard,
onKeyDown,
pause,
onMouseOver,
onMouseOut,
wrap,
touch,
onTouchStart,
onTouchMove,
onTouchEnd,
prevIcon,
prevLabel,
nextIcon,
nextLabel,
className,
children,
...props
} = useUncontrolled(uncontrolledProps, {
activeIndex: 'onSelect',
});
componentDidMount() {
this.cycle();
}
const prefix = useBootstrapPrefix(bsPrefix, 'carousel');
static getDerivedStateFromProps(
nextProps,
{ activeIndex: previousActiveIndex },
) {
if (nextProps.activeIndex !== previousActiveIndex) {
const lastPossibleIndex = countChildren(nextProps.children) - 1;
const nextDirectionRef = useRef(null);
const [direction, setDirection] = useState('next');
const nextIndex = Math.max(
0,
Math.min(nextProps.activeIndex, lastPossibleIndex),
);
const [isSliding, setIsSliding] = useState(false);
const [renderedActiveIndex, setRenderedActiveIndex] = useState(activeIndex);
let direction;
if (
(nextIndex === 0 && previousActiveIndex >= lastPossibleIndex) ||
previousActiveIndex <= nextIndex
) {
direction = 'next';
if (!isSliding && activeIndex !== renderedActiveIndex) {
if (nextDirectionRef.current) {
setDirection(nextDirectionRef.current);
nextDirectionRef.current = null;
} else {
direction = 'prev';
setDirection(activeIndex > renderedActiveIndex ? 'next' : 'prev');
}
return {
direction,
previousActiveIndex,
activeIndex: nextIndex,
};
}
return null;
if (slide) {
setIsSliding(true);
}
componentDidUpdate(_, prevState) {
const { bsPrefix, slide, onSlideEnd } = this.props;
if (
!slide ||
this.state.activeIndex === prevState.activeIndex ||
this._isSliding
)
return;
setRenderedActiveIndex(activeIndex);
}
const { activeIndex, direction } = this.state;
let orderClassName, directionalClassName;
const numChildren = React.Children.toArray(children).filter(
React.isValidElement,
).length;
if (direction === 'next') {
orderClassName = `${bsPrefix}-item-next`;
directionalClassName = `${bsPrefix}-item-left`;
} else if (direction === 'prev') {
orderClassName = `${bsPrefix}-item-prev`;
directionalClassName = `${bsPrefix}-item-right`;
const prev = useCallback(
event => {
if (isSliding) {
return;
}
this._isSliding = true;
let nextActiveIndex = renderedActiveIndex - 1;
if (nextActiveIndex < 0) {
if (!wrap) {
return;
}
this.pause();
nextActiveIndex = numChildren - 1;
}
// eslint-disable-next-line react/no-did-update-set-state
this.safeSetState(
{ prevClasses: 'active', currentClasses: orderClassName },
() => {
const items = this.carousel.current.children;
const nextElement = items[activeIndex];
triggerBrowserReflow(nextElement);
nextDirectionRef.current = 'prev';
this.safeSetState(
{
prevClasses: classNames('active', directionalClassName),
currentClasses: classNames(orderClassName, directionalClassName),
onSelect(nextActiveIndex, event);
},
() =>
transitionEnd(nextElement, () => {
this.safeSetState(
{ prevClasses: '', currentClasses: 'active' },
this.handleSlideEnd,
[isSliding, renderedActiveIndex, onSelect, wrap, numChildren],
);
if (onSlideEnd) {
onSlideEnd();
// This is used in the setInterval, so it should not invalidate.
const next = useEventCallback(event => {
if (isSliding) {
return;
}
}),
);
},
);
let nextActiveIndex = renderedActiveIndex + 1;
if (nextActiveIndex >= numChildren) {
if (!wrap) {
return;
}
componentWillUnmount() {
clearTimeout(this.timeout);
this.isUnmounted = true;
nextActiveIndex = 0;
}
handleTouchStart = e => {
this.setState({ touchStartX: e.changedTouches[0].screenX });
};
nextDirectionRef.current = 'next';
handleTouchEnd = e => {
// If the swipe is under the threshold, don't do anything.
if (
Math.abs(e.changedTouches[0].screenX - this.state.touchStartX) <
SWIPE_THRESHOLD
)
onSelect(nextActiveIndex, event);
});
const elementRef = useRef();
useImperativeHandle(ref, () => ({ element: elementRef.current, prev, next }));
// This is used in the setInterval, so it should not invalidate.
const nextWhenVisible = useEventCallback(() => {
if (!document.hidden && isVisible(elementRef.current)) {
next();
}
});
const slideDirection = direction === 'next' ? 'left' : 'right';
useUpdateEffect(() => {
if (slide) {
// These callbacks will be handled by the <Transition> callbacks.
return;
}
if (e.changedTouches[0].screenX < this.state.touchStartX) {
// Swiping left to navigate to next item.
this.handleNext(e);
} else {
// Swiping right to navigate to previous item.
this.handlePrev(e);
if (onSlide) {
onSlide(renderedActiveIndex, slideDirection);
}
};
if (onSlid) {
onSlid(renderedActiveIndex, slideDirection);
}
}, [renderedActiveIndex]);
handleSlideEnd = () => {
const pendingIndex = this._pendingIndex;
this._isSliding = false;
this._pendingIndex = null;
const orderClassName = `${prefix}-item-${direction}`;
const directionalClassName = `${prefix}-item-${slideDirection}`;
if (pendingIndex != null) this.to(pendingIndex);
else this.cycle();
};
const handleEnter = useCallback(
node => {
triggerBrowserReflow(node);
handleMouseOut = () => {
this.cycle();
};
if (onSlide) {
onSlide(renderedActiveIndex, slideDirection);
}
},
[onSlide, renderedActiveIndex, slideDirection],
);
handleMouseOver = () => {
if (this.props.pauseOnHover) this.pause();
};
const handleEntered = useCallback(() => {
setIsSliding(false);
handleKeyDown = event => {
if (/input|textarea/i.test(event.target.tagName)) return;
if (onSlid) {
onSlid(renderedActiveIndex, slideDirection);
}
}, [onSlid, renderedActiveIndex, slideDirection]);
const handleKeyDown = useCallback(
event => {
if (keyboard && !/input|textarea/i.test(event.target.tagName)) {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
this.handlePrev(event);
break;
prev(event);
return;
case 'ArrowRight':
event.preventDefault();
this.handleNext(event);
break;
next(event);
return;
default:
break;
}
};
handleNextWhenVisible = () => {
if (
!this.isUnmounted &&
!document.hidden &&
styles(this.carousel.current, 'visibility') !== 'hidden'
) {
this.handleNext();
}
};
handleNext = e => {
if (this._isSliding) return;
const { wrap, activeIndex } = this.props;
if (onKeyDown) {
onKeyDown(event);
}
},
[keyboard, onKeyDown, prev, next],
);
let index = activeIndex + 1;
const count = countChildren(this.props.children);
const [pausedOnHover, setPausedOnHover] = useState(false);
if (index > count - 1) {
if (!wrap) return;
const handleMouseOver = useCallback(
event => {
if (pause === 'hover') {
setPausedOnHover(true);
}
index = 0;
if (onMouseOver) {
onMouseOver(event);
}
},
[pause, onMouseOver],
);
this.select(index, e, 'next');
};
const handleMouseOut = useCallback(
event => {
setPausedOnHover(false);
handlePrev = e => {
if (this._isSliding) return;
if (onMouseOut) {
onMouseOut(event);
}
},
[onMouseOut],
);
const { wrap, activeIndex } = this.props;
const touchStartXRef = useRef(0);
const touchDeltaXRef = useRef(0);
const [pausedOnTouch, setPausedOnTouch] = useState(false);
const touchUnpauseTimeout = useTimeout();
let index = activeIndex - 1;
const handleTouchStart = useCallback(
event => {
touchStartXRef.current = event.touches[0].clientX;
touchDeltaXRef.current = 0;
if (index < 0) {
if (!wrap) return;
index = countChildren(this.props.children) - 1;
if (touch) {
setPausedOnTouch(true);
}
this.select(index, e, 'prev');
};
safeSetState(state, cb) {
if (this.isUnmounted) return;
this.setState(state, () => !this.isUnmounted && cb());
if (onTouchStart) {
onTouchStart(event);
}
},
[touch, onTouchStart],
);
// This might be a public API.
pause() {
this._isPaused = true;
clearInterval(this._interval);
this._interval = null;
const handleTouchMove = useCallback(
event => {
if (event.touches && event.touches.length > 1) {
touchDeltaXRef.current = 0;
} else {
touchDeltaXRef.current =
event.touches[0].clientX - touchStartXRef.current;
}
cycle() {
this._isPaused = false;
clearInterval(this._interval);
this._interval = null;
if (this.props.interval && !this._isPaused) {
this._interval = setInterval(
document.visibilityState ? this.handleNextWhenVisible : this.handleNext,
this.props.interval,
);
}
if (onTouchMove) {
onTouchMove(event);
}
},
[onTouchMove],
);
to(index, event) {
const { children } = this.props;
if (index < 0 || index > countChildren(children) - 1) {
return;
}
const handleTouchEnd = useCallback(
event => {
if (touch) {
const touchDeltaX = touchDeltaXRef.current;
if (this._isSliding) {
this._pendingIndex = index;
if (Math.abs(touchDeltaX) <= SWIPE_THRESHOLD) {
return;
}
this.select(index, event);
if (touchDeltaX > 0) {
prev(event);