UNPKG

react-dates-rtl

Version:

Based on react-dates by airbnb [with RTL support]

842 lines (711 loc) 26.5 kB
import React from 'react'; import PropTypes from 'prop-types'; import shallowCompare from 'react-addons-shallow-compare'; import ReactDOM from 'react-dom'; import { forbidExtraProps, nonNegativeInteger } from 'airbnb-prop-types'; import moment from 'moment'; import cx from 'classnames'; import throttle from 'lodash.throttle'; import { DayPickerPhrases } from '../defaultPhrases'; import getPhrasePropTypes from '../utils/getPhrasePropTypes'; import OutsideClickHandler from './OutsideClickHandler'; import CalendarMonthGrid from './CalendarMonthGrid'; import DayPickerNavigation from './DayPickerNavigation'; import DayPickerKeyboardShortcuts, { TOP_LEFT, TOP_RIGHT, BOTTOM_RIGHT, } from './DayPickerKeyboardShortcuts'; import getTransformStyles from '../utils/getTransformStyles'; import getCalendarMonthWidth from '../utils/getCalendarMonthWidth'; import isTouchDevice from '../utils/isTouchDevice'; import getActiveElement from '../utils/getActiveElement'; import ScrollableOrientationShape from '../shapes/ScrollableOrientationShape'; import { HORIZONTAL_ORIENTATION, VERTICAL_ORIENTATION, VERTICAL_SCROLLABLE, DAY_SIZE, } from '../../constants'; const MONTH_PADDING = 23; const DAY_PICKER_PADDING = 9; const PREV_TRANSITION = 'prev'; const NEXT_TRANSITION = 'next'; const propTypes = forbidExtraProps({ // calendar presentation props enableOutsideDays: PropTypes.bool, numberOfMonths: PropTypes.number, orientation: ScrollableOrientationShape, withPortal: PropTypes.bool, onOutsideClick: PropTypes.func, hidden: PropTypes.bool, initialVisibleMonth: PropTypes.func, renderCalendarInfo: PropTypes.func, daySize: nonNegativeInteger, isRTL: PropTypes.bool, // navigation props navPrev: PropTypes.node, navNext: PropTypes.node, onPrevMonthClick: PropTypes.func, onNextMonthClick: PropTypes.func, // day props modifiers: PropTypes.object, renderDay: PropTypes.func, onDayClick: PropTypes.func, onDayMouseEnter: PropTypes.func, onDayMouseLeave: PropTypes.func, // accessibility props isFocused: PropTypes.bool, getFirstFocusableDay: PropTypes.func, onBlur: PropTypes.func, showKeyboardShortcuts: PropTypes.bool, // internationalization monthFormat: PropTypes.string, phrases: PropTypes.shape(getPhrasePropTypes(DayPickerPhrases)), }); export const defaultProps = { // calendar presentation props enableOutsideDays: false, numberOfMonths: 2, orientation: HORIZONTAL_ORIENTATION, withPortal: false, onOutsideClick() {}, hidden: false, initialVisibleMonth: () => moment(), renderCalendarInfo: null, daySize: DAY_SIZE, isRTL: false, // navigation props navPrev: null, navNext: null, onPrevMonthClick() {}, onNextMonthClick() {}, // day props modifiers: {}, renderDay: null, onDayClick() {}, onDayMouseEnter() {}, onDayMouseLeave() {}, // accessibility props isFocused: false, getFirstFocusableDay: null, onBlur() {}, showKeyboardShortcuts: false, // internationalization monthFormat: 'MMMM YYYY', phrases: DayPickerPhrases, }; function applyTransformStyles(el, transform, opacity = '') { const transformStyles = getTransformStyles(transform); transformStyles.opacity = opacity; Object.keys(transformStyles).forEach((styleKey) => { // eslint-disable-next-line no-param-reassign el.style[styleKey] = transformStyles[styleKey]; }); } export function calculateDimension(el, axis, borderBox = false, withMargin = false) { if (!el) { return 0; } const axisStart = axis === 'width' ? 'Left' : 'Top'; const axisEnd = axis === 'width' ? 'Right' : 'Bottom'; // Only read styles if we need to const style = (!borderBox || withMargin) ? window.getComputedStyle(el) : null; // Offset includes border and padding const { offsetWidth, offsetHeight } = el; let size = axis === 'width' ? offsetWidth : offsetHeight; // Get the inner size if (!borderBox) { size -= ( parseFloat(style[`padding${axisStart}`]) + parseFloat(style[`padding${axisEnd}`]) + parseFloat(style[`border${axisStart}Width`]) + parseFloat(style[`border${axisEnd}Width`]) ); } // Apply margin if (withMargin) { size += (parseFloat(style[`margin${axisStart}`]) + parseFloat(style[`margin${axisEnd}`])); } return size; } function getMonthHeight(el) { const caption = el.querySelector('.js-CalendarMonth__caption'); const grid = el.querySelector('.js-CalendarMonth__grid'); // Need to separate out table children for FF // Add an additional +1 for the border return ( calculateDimension(caption, 'height', true, true) + calculateDimension(grid, 'height') + 1 ); } export default class DayPicker extends React.Component { constructor(props) { super(props); const currentMonth = props.hidden ? moment() : props.initialVisibleMonth(); const translationValueRTL = -getCalendarMonthWidth(props.daySize); let focusedDate = currentMonth.clone().startOf('month'); if (props.getFirstFocusableDay) { focusedDate = props.getFirstFocusableDay(currentMonth); } this.hasSetInitialVisibleMonth = !props.hidden; this.state = { translationValueRTL, currentMonth, monthTransition: null, translationValue: props.isRTL && this.isHorizontal() ? translationValueRTL : 0, scrollableMonthMultiple: 1, calendarMonthWidth: getCalendarMonthWidth(props.daySize), focusedDate: (!props.hidden || props.isFocused) ? focusedDate : null, nextFocusedDate: null, showKeyboardShortcuts: props.showKeyboardShortcuts, onKeyboardShortcutsPanelClose() {}, isTouchDevice: isTouchDevice(), withMouseInteractions: true, }; this.onKeyDown = this.onKeyDown.bind(this); this.onPrevMonthClick = this.onPrevMonthClick.bind(this); this.onNextMonthClick = this.onNextMonthClick.bind(this); this.multiplyScrollableMonths = this.multiplyScrollableMonths.bind(this); this.updateStateAfterMonthTransition = this.updateStateAfterMonthTransition.bind(this); this.openKeyboardShortcutsPanel = this.openKeyboardShortcutsPanel.bind(this); this.closeKeyboardShortcutsPanel = this.closeKeyboardShortcutsPanel.bind(this); } componentDidMount() { this.setState({ isTouchDevice: isTouchDevice() }); if (this.isHorizontal()) { this.adjustDayPickerHeight(); this.initializeDayPickerWidth(); } } componentWillReceiveProps(nextProps) { const { hidden, isFocused, showKeyboardShortcuts, onBlur } = nextProps; const { currentMonth } = this.state; if (!hidden) { if (!this.hasSetInitialVisibleMonth) { this.hasSetInitialVisibleMonth = true; this.setState({ currentMonth: nextProps.initialVisibleMonth(), }); } if (!this.dayPickerWidth && this.isHorizontal()) { this.initializeDayPickerWidth(); this.adjustDayPickerHeight(); } } if (nextProps.daySize !== this.props.daySize) { this.setState({ calendarMonthWidth: getCalendarMonthWidth(nextProps.daySize), }); } if (isFocused !== this.props.isFocused) { if (isFocused) { const focusedDate = this.getFocusedDay(currentMonth); let onKeyboardShortcutsPanelClose = this.state.onKeyboardShortcutsPanelClose; if (nextProps.showKeyboardShortcuts) { // the ? shortcut came from the input and we should return input there once it is close onKeyboardShortcutsPanelClose = onBlur; } this.setState({ showKeyboardShortcuts, onKeyboardShortcutsPanelClose, focusedDate, withMouseInteractions: false, }); } else { this.setState({ focusedDate: null }); } } } shouldComponentUpdate(nextProps, nextState) { return shallowCompare(this, nextProps, nextState); } componentDidUpdate(prevProps, prevState) { const { monthTransition, currentMonth, focusedDate } = this.state; if (monthTransition || !currentMonth.isSame(prevState.currentMonth)) { if (this.isHorizontal()) { this.adjustDayPickerHeight(); } } if ( (!prevProps.isFocused && this.props.isFocused && !focusedDate) || (!prevProps.showKeyboardShortcuts && this.props.showKeyboardShortcuts) ) { this.container.focus(); } } onKeyDown(e) { e.stopPropagation(); this.setState({ withMouseInteractions: false }); const { onBlur } = this.props; const { focusedDate, showKeyboardShortcuts } = this.state; if (!focusedDate) return; const newFocusedDate = focusedDate.clone(); let didTransitionMonth = false; // focus might be anywhere when the keyboard shortcuts panel is opened so we want to // return it to wherever it was before when the panel was opened const activeElement = getActiveElement(); const onKeyboardShortcutsPanelClose = () => { if (activeElement) activeElement.focus(); }; switch (e.key) { case 'ArrowUp': e.preventDefault(); newFocusedDate.subtract(1, 'week'); didTransitionMonth = this.maybeTransitionPrevMonth(newFocusedDate); break; case 'ArrowLeft': e.preventDefault(); newFocusedDate.subtract(1, 'day'); didTransitionMonth = this.maybeTransitionPrevMonth(newFocusedDate); break; case 'Home': e.preventDefault(); newFocusedDate.startOf('week'); didTransitionMonth = this.maybeTransitionPrevMonth(newFocusedDate); break; case 'PageUp': e.preventDefault(); newFocusedDate.subtract(1, 'month'); didTransitionMonth = this.maybeTransitionPrevMonth(newFocusedDate); break; case 'ArrowDown': e.preventDefault(); newFocusedDate.add(1, 'week'); didTransitionMonth = this.maybeTransitionNextMonth(newFocusedDate); break; case 'ArrowRight': e.preventDefault(); newFocusedDate.add(1, 'day'); didTransitionMonth = this.maybeTransitionNextMonth(newFocusedDate); break; case 'End': e.preventDefault(); newFocusedDate.endOf('week'); didTransitionMonth = this.maybeTransitionNextMonth(newFocusedDate); break; case 'PageDown': e.preventDefault(); newFocusedDate.add(1, 'month'); didTransitionMonth = this.maybeTransitionNextMonth(newFocusedDate); break; case '?': this.openKeyboardShortcutsPanel(onKeyboardShortcutsPanelClose); break; case 'Escape': if (showKeyboardShortcuts) { this.closeKeyboardShortcutsPanel(); } else { onBlur(); } break; default: break; } // If there was a month transition, do not update the focused date until the transition has // completed. Otherwise, attempting to focus on a DOM node may interrupt the CSS animation. If // didTransitionMonth is true, the focusedDate gets updated in #updateStateAfterMonthTransition if (!didTransitionMonth) { this.setState({ focusedDate: newFocusedDate, }); } } onPrevMonthClick(nextFocusedDate, e) { const { translationValueRTL } = this.state; const { isRTL } = this.props; if (e) e.preventDefault(); if (this.props.onPrevMonthClick) { this.props.onPrevMonthClick(e); } let translationValue = this.isVertical() ? this.getMonthHeightByIndex(0) : this.dayPickerWidth; if (isRTL && this.isHorizontal()) { translationValue = translationValueRTL - this.dayPickerWidth; } // The first CalendarMonth is always positioned absolute at top: 0 or left: 0 // so we need to transform it to the appropriate location before the animation. // This behavior is because we would otherwise need a double-render in order to // adjust the container position once we had the height the first calendar // (ie first draw all the calendar, then in a second render, use the first calendar's // height to position the container). Variable calendar heights, amirite? <3 Maja this.translateFirstDayPickerForAnimation(translationValue); this.setState({ monthTransition: PREV_TRANSITION, translationValue, nextFocusedDate, }); } onNextMonthClick(nextFocusedDate, e) { if (e) e.preventDefault(); if (this.props.onNextMonthClick) { this.props.onNextMonthClick(e); } const convertedTranslationValueRTL = this.dayPickerWidth + this.state.translationValueRTL; let translationValue = this.props.isRTL ? convertedTranslationValueRTL : -this.dayPickerWidth; if (this.isVertical()) { translationValue = -this.getMonthHeightByIndex(1); } this.setState({ monthTransition: NEXT_TRANSITION, translationValue, nextFocusedDate, }); } getFocusedDay(newMonth) { const { getFirstFocusableDay } = this.props; let focusedDate; if (getFirstFocusableDay) { focusedDate = getFirstFocusableDay(newMonth); } if (newMonth && (!focusedDate || !this.isDayVisible(focusedDate, newMonth))) { focusedDate = newMonth.clone().startOf('month'); } return focusedDate; } getMonthHeightByIndex(i) { return getMonthHeight(this.transitionContainer.querySelectorAll('.CalendarMonth')[i]); } maybeTransitionNextMonth(newFocusedDate) { const { focusedDate } = this.state; const newFocusedDateMonth = newFocusedDate.month(); const focusedDateMonth = focusedDate.month(); if (newFocusedDateMonth !== focusedDateMonth && !this.isDayVisible(newFocusedDate)) { this.onNextMonthClick(newFocusedDate); return true; } return false; } maybeTransitionPrevMonth(newFocusedDate) { const { focusedDate } = this.state; const newFocusedDateMonth = newFocusedDate.month(); const focusedDateMonth = focusedDate.month(); if (newFocusedDateMonth !== focusedDateMonth && !this.isDayVisible(newFocusedDate)) { this.onPrevMonthClick(newFocusedDate); return true; } return false; } multiplyScrollableMonths(e) { if (e) e.preventDefault(); this.setState({ scrollableMonthMultiple: this.state.scrollableMonthMultiple + 1, }); } isDayVisible(day, newMonth) { const { numberOfMonths } = this.props; const { currentMonth } = this.state; const month = newMonth || currentMonth; const firstDayOfFirstMonth = month.clone().startOf('month'); const lastDayOfLastMonth = month.clone().add(numberOfMonths - 1, 'months').endOf('month'); return !day.isBefore(firstDayOfFirstMonth) && !day.isAfter(lastDayOfLastMonth); } isHorizontal() { return this.props.orientation === HORIZONTAL_ORIENTATION; } isVertical() { return this.props.orientation === VERTICAL_ORIENTATION || this.props.orientation === VERTICAL_SCROLLABLE; } initializeDayPickerWidth() { this.dayPickerWidth = calculateDimension( // eslint-disable-next-line react/no-find-dom-node ReactDOM.findDOMNode(this.calendarMonthGrid).querySelector('.CalendarMonth'), 'width', true, ); } updateStateAfterMonthTransition() { const { currentMonth, monthTransition, focusedDate, nextFocusedDate, translationValueRTL, } = this.state; if (!monthTransition) return; const newMonth = currentMonth.clone(); if (monthTransition === PREV_TRANSITION) { newMonth.subtract(1, 'month'); } else if (monthTransition === NEXT_TRANSITION) { newMonth.add(1, 'month'); } let newFocusedDate = null; if (nextFocusedDate) { newFocusedDate = nextFocusedDate; } else if (focusedDate) { newFocusedDate = this.getFocusedDay(newMonth); } // clear the previous transforms applyTransformStyles( // eslint-disable-next-line react/no-find-dom-node ReactDOM.findDOMNode(this.calendarMonthGrid).querySelector('.CalendarMonth'), 'none', ); this.setState({ currentMonth: newMonth, monthTransition: null, translationValue: (this.props.isRTL && this.isHorizontal()) ? translationValueRTL : 0, nextFocusedDate: null, focusedDate: newFocusedDate, }, () => { // we don't want to focus on the relevant calendar day after a month transition // if the user is navigating around using a mouse if (this.state.withMouseInteractions) { const activeElement = getActiveElement(); if (activeElement && activeElement !== document.body) { activeElement.blur(); } } }); } adjustDayPickerHeight() { const heights = []; Array.prototype.forEach.call(this.transitionContainer.querySelectorAll('.CalendarMonth'), (el) => { if (el.getAttribute('data-visible') === 'true') { heights.push(getMonthHeight(el)); } }, ); const newMonthHeight = Math.max(...heights) + MONTH_PADDING; if (newMonthHeight !== calculateDimension(this.transitionContainer, 'height')) { this.monthHeight = newMonthHeight; this.transitionContainer.style.height = `${newMonthHeight}px`; } } translateFirstDayPickerForAnimation(translationValue) { const shouldRTL = this.props.isRTL && this.isHorizontal(); let convertedTranslationValue = -translationValue; if (shouldRTL) { const positiveTranslationValue = Math.abs(translationValue - this.state.translationValueRTL); convertedTranslationValue = positiveTranslationValue; } const transformType = this.isVertical() ? 'translateY' : 'translateX'; const transformValue = `${transformType}(${convertedTranslationValue}px)`; applyTransformStyles( this.transitionContainer.querySelector('.CalendarMonth'), transformValue, 1, ); } openKeyboardShortcutsPanel(onCloseCallBack) { this.setState({ showKeyboardShortcuts: true, onKeyboardShortcutsPanelClose: onCloseCallBack, }); } closeKeyboardShortcutsPanel() { const { onKeyboardShortcutsPanelClose } = this.state; if (onKeyboardShortcutsPanelClose) { onKeyboardShortcutsPanelClose(); } this.setState({ onKeyboardShortcutsPanelClose: null, showKeyboardShortcuts: false, }); } renderNavigation() { const { navPrev, navNext, orientation, phrases, isRTL, } = this.props; let onNextMonthClick; if (orientation === VERTICAL_SCROLLABLE) { onNextMonthClick = this.multiplyScrollableMonths; } else { onNextMonthClick = (e) => { this.onNextMonthClick(null, e); }; } return ( <DayPickerNavigation onPrevMonthClick={(e) => { this.onPrevMonthClick(null, e); }} onNextMonthClick={onNextMonthClick} navPrev={navPrev} navNext={navNext} orientation={orientation} phrases={phrases} isRTL={isRTL} /> ); } renderWeekHeader(index) { const { daySize, orientation, isRTL } = this.props; const { calendarMonthWidth } = this.state; const verticalScrollable = orientation === VERTICAL_SCROLLABLE; const horizontalStyle = { left: index * calendarMonthWidth, }; const verticalStyle = { marginLeft: -calendarMonthWidth / 2, }; let style = {}; // no styles applied to the vertical-scrollable orientation if (this.isHorizontal()) { style = horizontalStyle; } else if (this.isVertical() && !verticalScrollable) { style = verticalStyle; } const header = []; for (let i = 0; i < 7; i += 1) { header.push( <li key={i} style={{ width: daySize }}> <small>{moment().weekday(i).format('dd')}</small> </li>, ); } return ( <div className={cx('DayPicker__week-header', { 'DayPicker__week-header--rtl': isRTL })} key={`week-${index}`} style={style} > <ul> {header} </ul> </div> ); } render() { const { calendarMonthWidth, currentMonth, monthTransition, translationValue, scrollableMonthMultiple, focusedDate, showKeyboardShortcuts, isTouchDevice: isTouch, } = this.state; const { enableOutsideDays, numberOfMonths, orientation, modifiers, withPortal, onDayClick, onDayMouseEnter, onDayMouseLeave, renderDay, renderCalendarInfo, onOutsideClick, monthFormat, daySize, isFocused, phrases, isRTL, } = this.props; const numOfWeekHeaders = this.isVertical() ? 1 : numberOfMonths; const weekHeaders = []; for (let i = 0; i < numOfWeekHeaders; i += 1) { weekHeaders.push(this.renderWeekHeader(i)); } let firstVisibleMonthIndex = 1; if (monthTransition === PREV_TRANSITION) { firstVisibleMonthIndex -= 1; } else if (monthTransition === NEXT_TRANSITION) { firstVisibleMonthIndex += 1; } const verticalScrollable = this.props.orientation === VERTICAL_SCROLLABLE; const dayPickerClassNames = cx('DayPicker', { 'DayPicker--horizontal': this.isHorizontal(), 'DayPicker--vertical': this.isVertical(), 'DayPicker--vertical-scrollable': verticalScrollable, 'DayPicker--portal': withPortal, }); const transitionContainerClasses = cx('transition-container', { 'transition-container--horizontal': this.isHorizontal(), 'transition-container--vertical': this.isVertical(), }); const horizontalWidth = (calendarMonthWidth * numberOfMonths) + (2 * DAY_PICKER_PADDING); // this is a kind of made-up value that generally looks good. we'll // probably want to let the user set this explicitly. const verticalHeight = 1.75 * calendarMonthWidth; const dayPickerStyle = { width: this.isHorizontal() && horizontalWidth, // These values are to center the datepicker (approximately) on the page marginLeft: this.isHorizontal() && withPortal && -horizontalWidth / 2, marginTop: this.isHorizontal() && withPortal && -calendarMonthWidth / 2, }; const transitionContainerStyle = { width: this.isHorizontal() && horizontalWidth, height: this.isVertical() && !verticalScrollable && !withPortal && verticalHeight, }; const isCalendarMonthGridAnimating = monthTransition !== null; const transformType = this.isVertical() ? 'translateY' : 'translateX'; const transformValue = `${transformType}(${translationValue}px)`; const shouldFocusDate = !isCalendarMonthGridAnimating && isFocused; let keyboardShortcutButtonLocation = BOTTOM_RIGHT; if (this.isVertical()) { keyboardShortcutButtonLocation = withPortal ? TOP_LEFT : TOP_RIGHT; } return ( <div className={dayPickerClassNames} style={dayPickerStyle} > <OutsideClickHandler onOutsideClick={onOutsideClick}> <div className="DayPicker__week-headers" aria-hidden="true" role="presentation" > {weekHeaders} </div> <div // eslint-disable-line jsx-a11y/no-static-element-interactions className="DayPicker__focus-region" ref={(ref) => { this.container = ref; }} onClick={(e) => { e.stopPropagation(); }} onKeyDown={throttle(this.onKeyDown, 300)} onMouseUp={() => { this.setState({ withMouseInteractions: true }); }} role="region" tabIndex={-1} > {!verticalScrollable && this.renderNavigation()} <div className={transitionContainerClasses} ref={(ref) => { this.transitionContainer = ref; }} style={transitionContainerStyle} > <CalendarMonthGrid ref={(ref) => { this.calendarMonthGrid = ref; }} transformValue={transformValue} enableOutsideDays={enableOutsideDays} firstVisibleMonthIndex={firstVisibleMonthIndex} initialMonth={currentMonth} isAnimating={isCalendarMonthGridAnimating} modifiers={modifiers} orientation={orientation} numberOfMonths={numberOfMonths * scrollableMonthMultiple} onDayClick={onDayClick} onDayMouseEnter={onDayMouseEnter} onDayMouseLeave={onDayMouseLeave} renderDay={renderDay} onMonthTransitionEnd={this.updateStateAfterMonthTransition} monthFormat={monthFormat} daySize={daySize} isFocused={shouldFocusDate} focusedDate={focusedDate} phrases={phrases} isRTL={isRTL} /> {verticalScrollable && this.renderNavigation()} </div> {!isTouch && <DayPickerKeyboardShortcuts block={this.isVertical() && !withPortal} buttonLocation={keyboardShortcutButtonLocation} showKeyboardShortcutsPanel={showKeyboardShortcuts} openKeyboardShortcutsPanel={this.openKeyboardShortcutsPanel} closeKeyboardShortcutsPanel={this.closeKeyboardShortcutsPanel} phrases={phrases} /> } </div> {renderCalendarInfo && renderCalendarInfo()} </OutsideClickHandler> </div> ); } } DayPicker.propTypes = propTypes; DayPicker.defaultProps = defaultProps;