react-images
Version:
A mobile-friendly, highly customizable, carousel component for displaying media in ReactJS
396 lines (355 loc) • 11.5 kB
JavaScript
// @flow
// @jsx glam
import React, { Component, type ElementRef } from 'react'
import { findDOMNode } from 'react-dom'
import glam from 'glam'
import rafScheduler from 'raf-schd'
import { ViewPager, Frame, Track, View as PageView } from 'react-view-pager'
const viewPagerStyles = {
flex: '1 1 auto',
position: 'relative',
}
const frameStyles = { outline: 0 }
import { defaultCarouselComponents, type CarouselComponents } from './defaultComponents'
import { defaultCarouselStyles, type CarouselStylesConfig } from '../styles'
import { type ModalProps } from './Modal/Modal'
import { className, isTouch } from '../utils'
import formatters from '../formatters'
import { type ViewsType } from '../types'
import componentBaseClassNames from './componentBaseClassNames'
type SpringConfig = { [key: string]: number }
export type fn = any => void
export type IndicesType = Array<number>
export type CarouselProps = {
/* Replace any of the carousel components */
components?: CarouselComponents,
/* Take control of the component's view index state */
currentIndex: number,
// See https://github.com/souporserious/react-view-pager#frame-props
frameProps: {
accessibility: boolean,
autoSize: true | false | 'width' | 'height',
springConfig: SpringConfig,
tag: any,
},
/* Formatters get called when language is used, defaults use english. */
formatters: typeof formatters,
/* Duration, in milliseconds, to wait before hiding controls when the user is idle */
hideControlsWhenIdle?: number | false,
/* Force hide or show controls on touch devices */
showNavigationOnTouchDevice?: boolean,
/* When envoked within a modal, props are cloned from the modal */
modalProps?: ModalProps,
/* Style modifier methods */
styles: CarouselStylesConfig,
// See https://github.com/souporserious/react-view-pager#track-props
trackProps: {
align: number,
animations: Array<{ props: string, stops: Array<[number, number]> }>,
axis: 'x' | 'y',
contain: boolean,
currentView: any,
flickTimeout: number,
infinite: boolean,
instant: boolean,
onRest: fn,
onScroll: fn,
onSwipeEnd: fn,
onSwipeMove: fn,
onSwipeStart: fn,
onViewChange: number => void,
springConfig: SpringConfig,
swipe: true | false | 'mouse' | 'touch',
swipeThreshold: number,
tag: any,
viewsToMove: number,
viewsToShow: number | 'auto',
},
/* The items to render in the carousel */
views: ViewsType,
}
export type CarouselState = {
currentIndex: number,
interactionIsIdle: boolean,
}
const defaultProps = {
currentIndex: 0,
formatters,
hideControlsWhenIdle: 3000,
showNavigationOnTouchDevice: false,
styles: {},
trackProps: {
instant: !isTouch(),
swipe: 'touch',
},
}
const trackBaseClassName = componentBaseClassNames.Track
class Carousel extends Component<CarouselProps, CarouselState> {
commonProps: any // TODO
components: CarouselComponents
container: HTMLElement
footer: HTMLElement
frame: ElementRef<Frame>
header: HTMLElement
mounted: boolean = false
track: ElementRef<Track>
timer: number
static defaultProps = defaultProps
constructor(props: CarouselProps) {
super(props)
this.cacheComponents(props.components)
this.state = {
currentIndex: props.currentIndex,
interactionIsIdle: isTouch(),
}
}
componentDidMount() {
const { hideControlsWhenIdle, modalProps } = this.props
const isModal = Boolean(modalProps)
this.mounted = true
if (hideControlsWhenIdle && this.container) {
this.container.addEventListener('mousedown', this.handleMouseActivity)
this.container.addEventListener('mousemove', this.handleMouseActivity)
this.container.addEventListener('touchmove', this.handleMouseActivity)
}
if (isModal) {
this.focusViewFrame()
}
}
componentDidUpdate(prevProps: CarouselProps) {
if (prevProps.components !== this.props.components) {
this.cacheComponents(prevProps.components)
}
if (this.props.currentIndex !== prevProps.currentIndex) {
this.setState({ currentIndex: this.props.currentIndex })
}
}
componentWillUnmount() {
this.mounted = false
if (this.props.hideControlsWhenIdle && this.container) {
this.container.removeEventListener('mousedown', this.handleMouseActivity)
this.container.removeEventListener('mousemove', this.handleMouseActivity)
this.container.removeEventListener('touchmove', this.handleMouseActivity)
this.handleMouseActivity.cancel()
}
}
cacheComponents = (comps?: CarouselComponents) => {
this.components = defaultCarouselComponents(comps)
}
// ==============================
// Refs
// ==============================
getContainer = (ref: HTMLElement) => {
this.container = ref
}
getFooter = (ref: HTMLElement) => {
this.footer = ref
}
getFrame = (ref: Frame) => {
this.frame = findDOMNode(ref)
}
getHeader = (ref: HTMLElement) => {
this.header = ref
}
getTrack = (ref: Track) => {
this.track = ref
}
// ==============================
// Utilities
// ==============================
hasPreviousView = (): boolean => {
const { trackProps } = this.props
const { currentIndex } = this.state
return trackProps.infinite || currentIndex !== 0
}
hasNextView = (): boolean => {
const { trackProps, views } = this.props
const { currentIndex } = this.state
return trackProps.infinite || currentIndex !== views.length - 1
}
getStyles = (key: string, props: {}): {} => {
const base = defaultCarouselStyles[key](props)
base.boxSizing = 'border-box'
const custom = this.props.styles[key]
return custom ? custom(base, props) : base
}
// combine defaultProps with consumer props to maintain expected behaviour
getTrackProps = (props: CarouselProps) => {
return { ...defaultProps.trackProps, ...props.trackProps }
}
// combine defaultProps with consumer props to maintain expected behaviour
getFormatters = () => {
return { ...defaultProps.formatters, ...this.props.formatters }
}
getViewData = () => {
const { views } = this.props
const { currentIndex } = this.state
return views[currentIndex]
}
focusViewFrame = () => {
if (this.frame && document.activeElement !== this.frame) {
this.frame.focus()
}
}
prev = event => {
event.stopPropagation()
this.track.prev()
this.focusViewFrame()
}
next = event => {
event.stopPropagation()
this.track.next()
this.focusViewFrame()
}
// ==============================
// Handlers
// ==============================
handleMouseActivity = rafScheduler(() => {
clearTimeout(this.timer)
if (this.state.interactionIsIdle) {
this.setState({ interactionIsIdle: false })
}
this.timer = setTimeout(() => {
if (this.mounted) {
this.setState({ interactionIsIdle: true })
}
}, this.props.hideControlsWhenIdle)
})
handleViewChange = (indicies: IndicesType) => {
const { trackProps } = this.props
// simplify by enforcing number
const currentIndex = indicies[0]
this.setState({ currentIndex })
// call the consumer's onViewChange fn
if (trackProps && trackProps.onViewChange) {
trackProps.onViewChange(currentIndex)
}
}
// ==============================
// Renderers
// ==============================
renderNavigation = () => {
const { getNextLabel, getPrevLabel, getNextTitle, getPrevTitle } = this.getFormatters()
const { Navigation, NavigationPrev, NavigationNext } = this.components
const { commonProps } = this
const showPrev = this.hasPreviousView()
const showNext = this.hasNextView()
const showNav = (showPrev || showNext) && Navigation
return showNav ? (
<Navigation {...commonProps}>
{showPrev && (
<NavigationPrev
{...commonProps}
align="left"
innerProps={{
'aria-label': getPrevLabel(commonProps),
onClick: this.prev,
title: getPrevTitle(commonProps),
}}
/>
)}
{showNext && (
<NavigationNext
{...commonProps}
align="right"
innerProps={{
'aria-label': getNextLabel(commonProps),
onClick: this.next,
title: getNextTitle(commonProps),
}}
/>
)}
</Navigation>
) : null
}
renderFooter = () => {
const { Footer, FooterCaption, FooterCount } = this.components
const { commonProps } = this
return Footer ? (
<Footer
{...commonProps}
components={{
Caption: FooterCaption,
Count: FooterCount,
}}
innerProps={{ innerRef: this.getFooter }}
/>
) : null
}
renderHeader = () => {
const { Header, HeaderClose, HeaderFullscreen } = this.components
const { getCloseLabel, getFullscreenLabel } = this.getFormatters()
const { commonProps } = this
return Header ? (
<Header
{...commonProps}
getCloseLabel={getCloseLabel}
getFullscreenLabel={getFullscreenLabel}
components={{
CloseButton: HeaderClose,
FullscreenButton: HeaderFullscreen,
}}
data={this.getViewData()}
innerProps={{ innerRef: this.getHeader }}
/>
) : null
}
getCommonProps() {
const { frameProps, trackProps, modalProps, views, showNavigationOnTouchDevice } = this.props
const isModal = Boolean(modalProps)
const isFullscreen = Boolean(modalProps && modalProps.isFullscreen)
const { currentIndex, interactionIsIdle } = this.state
const currentView = this.getViewData()
return {
carouselProps: this.props,
currentIndex,
currentView,
formatters: this.props.formatters,
frameProps,
getStyles: this.getStyles,
showNavigationOnTouchDevice,
isFullscreen,
isModal,
modalProps,
interactionIsIdle,
trackProps,
views,
}
}
render() {
const { Container, View } = this.components
const { currentIndex } = this.state
const { frameProps, views } = this.props
const commonProps = (this.commonProps = this.getCommonProps())
return (
<Container {...commonProps} innerProps={{ innerRef: this.getContainer }}>
{this.renderHeader()}
<ViewPager tag="main" style={viewPagerStyles} className={className('pager')}>
<Frame {...frameProps} ref={this.getFrame} className={className('frame')} style={frameStyles} tabIndex="-1">
<Track
{...this.getTrackProps(this.props)}
style={{ display: 'flex', alignItems: 'center' }}
currentView={currentIndex}
className={className(trackBaseClassName)}
onViewChange={this.handleViewChange}
ref={this.getTrack}
>
{views &&
views.map((data, index) => {
return (
<PageView className={className('view-wrapper')} key={index}>
<View {...commonProps} data={data} index={index} />
</PageView>
)
})}
</Track>
</Frame>
{this.renderNavigation()}
</ViewPager>
{this.renderFooter()}
</Container>
)
}
}
export default Carousel
export type CarouselType = typeof Carousel