/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Children, Component, ReactElement, MouseEvent } from 'react' import { View } from '@instructure/ui-view/latest' import { safeCloneElement, callRenderProp, omitProps, withDeterministicId } from '@instructure/ui-react-utils' import { createChainedFunction } from '@instructure/ui-utils' import { logError as error } from '@instructure/console' import { AccessibleContent } from '@instructure/ui-a11y-content' import { withStyle } from '@instructure/emotion' import { Locale, DateTime, ApplyLocaleContext } from '@instructure/ui-i18n' import type { Moment } from '@instructure/ui-i18n' import generateStyle from './styles' import { Day } from './Day' import { allowedProps } from './props' import type { CalendarProps, CalendarState } from './props' import { Renderable } from '@instructure/shared-types' import { IconButton } from '@instructure/ui-buttons/latest' import { ChevronLeftInstUIIcon, ChevronRightInstUIIcon } from '@instructure/ui-icons' import { SimpleSelect } from '@instructure/ui-simple-select/latest' /** --- category: components --- **/ @withDeterministicId() @withStyle(generateStyle) class Calendar extends Component { static readonly componentId = 'Calendar' declare context: React.ContextType static contextType = ApplyLocaleContext static Day = Day static DAY_COUNT = 42 // 6 weeks visible static allowedProps = allowedProps static defaultProps = { as: 'span', role: 'table' } ref: Element | null = null private _weekdayHeaderIds constructor(props: CalendarProps) { super(props) this._weekdayHeaderIds = ( this.props.renderWeekdayLabels || this.defaultWeekdays ).reduce((ids: Record, _label, i) => { return { ...ids, [i]: this.props.deterministicId!('weekday-header') } }, {}) this.state = this.calculateState( this.locale(), this.timezone(), props.currentDate ) } handleRef = (el: Element | null) => { this.ref = el } componentDidMount() { this.props.makeStyles?.() } componentDidUpdate(prevProps: CalendarProps) { this.props.makeStyles?.() const isUpdated = prevProps.locale !== this.props.locale || prevProps.timezone !== this.props.timezone || prevProps.visibleMonth !== this.props.visibleMonth if (isUpdated) { this.setState(() => { return { ...this.calculateState( this.locale(), this.timezone(), this.props.currentDate ) } }) } } calculateState = (locale: string, timezone: string, currentDate?: string) => { const visibleMonth = this.props.visibleMonth || currentDate return { visibleMonth: visibleMonth ? DateTime.parse(visibleMonth, locale, timezone) : DateTime.now(locale, timezone), today: currentDate ? DateTime.parse(currentDate, locale, timezone) : DateTime.now(locale, timezone) } } get role() { return this.props.role === 'listbox' ? this.props.role : undefined } get hasPrevMonth() { // this is needed for locales that doesn't use the latin script for numbers e.g.: arabic const yearNumber = Number( this.state.visibleMonth .clone() .locale('en') .subtract({ months: 1 }) .format('YYYY') ) return ( !this.props.withYearPicker || (this.props.withYearPicker && yearNumber >= this.props.withYearPicker.startYear) ) } get hasNextMonth() { // this is needed for locales that doesn't use the latin script for numbers e.g.: arabic const yearNumber = Number( this.state.visibleMonth .clone() .locale('en') .add({ months: 1 }) .format('YYYY') ) return ( !this.props.withYearPicker || (this.props.withYearPicker && yearNumber <= this.props.withYearPicker.endYear) ) } renderMonthNavigationButtons = () => { const { renderNextMonthButton, renderPrevMonthButton } = this.props return { prevButton: renderPrevMonthButton ? ( callRenderProp(renderPrevMonthButton) ) : ( } screenReaderLabel="Previous month" /> ), nextButton: renderNextMonthButton ? ( callRenderProp(renderNextMonthButton) ) : ( } screenReaderLabel="Next month" /> ) } } handleMonthChange = (direction: 'prev' | 'next') => (e: React.MouseEvent) => { const { onRequestRenderNextMonth, onRequestRenderPrevMonth } = this.props const { visibleMonth } = this.state const newDate = visibleMonth.clone() if (direction === 'prev') { if (!this.hasPrevMonth) { return } if (onRequestRenderPrevMonth) { onRequestRenderPrevMonth( e, newDate.subtract({ months: 1 }).format('YYYY-MM') ) return } newDate.subtract({ months: 1 }) } else { if (!this.hasNextMonth) { return } if (onRequestRenderNextMonth) { onRequestRenderNextMonth( e, newDate.add({ months: 1 }).format('YYYY-MM') ) return } newDate.add({ months: 1 }) } this.setState({ visibleMonth: newDate }) } handleYearChange = ( e: React.SyntheticEvent, year: string ) => { const { withYearPicker } = this.props const { visibleMonth } = this.state const yearNumber = Number( DateTime.parse(year, this.locale(), this.timezone()) .locale('en') .format('YYYY') ) const newDate = visibleMonth.clone() if (withYearPicker?.onRequestYearChange) { withYearPicker.onRequestYearChange(e, yearNumber) return } newDate.year(yearNumber) this.setState({ visibleMonth: newDate }) } renderHeader() { const { renderNavigationLabel, styles, withYearPicker } = this.props const { visibleMonth } = this.state const { prevButton, nextButton } = this.renderMonthNavigationButtons() const cloneButton = ( button: ReactElement, onClick?: (e: React.MouseEvent) => void ) => safeCloneElement(button, { onClick: createChainedFunction( button.props.onClick as React.MouseEventHandler, onClick ) }) const style = [ styles?.navigation, ...(prevButton || nextButton ? [styles?.navigationWithButtons] : []) ] const yearList: string[] = [] if (withYearPicker) { const { startYear, endYear } = withYearPicker for (let year = endYear; year >= startYear!; year--) { // add the years to the list with the correct locale yearList.push( DateTime.parse( year.toString(), this.locale(), this.timezone() ).format('YYYY') ) } } return (
{prevButton && cloneButton(prevButton, this.handleMonthChange('prev'))} {renderNavigationLabel ? ( callRenderProp(renderNavigationLabel) ) : (
{visibleMonth.format('MMMM')}
{!withYearPicker ? (
{visibleMonth.format('YYYY')}
) : null}
)} {nextButton && cloneButton(nextButton, this.handleMonthChange('next'))}
{withYearPicker ? (
, { value }: { value?: string | number | undefined id?: string | undefined } ) => this.handleYearChange(e, `${value}`)} > {yearList.map((year) => ( {`${year}`} ))}
) : null}
) } renderBody() { return ( {this.renderWeekdayHeaders()}{this.renderDays()}
) } renderWeekdayHeaders() { const { styles } = this.props const renderWeekdayLabels = this.props.renderWeekdayLabels || this.defaultWeekdays const { length } = renderWeekdayLabels error( length === 7, `[Calendar] \`renderWeekdayLabels\` should be an array with 7 labels (one for each weekday). ${length} provided.` ) return ( {renderWeekdayLabels.map((label, i) => { return ( {callRenderProp(label)} ) })} ) } get defaultWeekdays() { const shortDayNames = DateTime.getLocalDayNamesOfTheWeek( this.locale(), 'short' ) const longDayNames = DateTime.getLocalDayNamesOfTheWeek( this.locale(), 'long' ) return [ {shortDayNames[0]} , {shortDayNames[1]} , {shortDayNames[2]} , {shortDayNames[3]} , {shortDayNames[4]} , {shortDayNames[5]} , {shortDayNames[6]} ] as Renderable[] } renderDays() { const { children } = this.props const childrenArr = Children.toArray( children ? children : this.renderDefaultdays() ) as ReactElement[] const { length } = childrenArr const role = this.role === 'listbox' ? 'presentation' : undefined error( length === Calendar.DAY_COUNT, `[Calendar] should have exactly ${Calendar.DAY_COUNT} children. ${length} provided.` ) return childrenArr .reduce((days: ReactElement[][], day, i) => { const index = Math.floor(i / 7) if (!days[index]) days.push([]) days[index].push(day) return days // 7xN 2D array of `Day`s }, []) .map((row) => ( {row.map((day, i) => ( {role === 'presentation' ? safeCloneElement(day, { 'aria-describedby': this._weekdayHeaderIds[i] }) : day} ))} )) } locale(): string { if (this.props.locale) { return this.props.locale } else if (this.context && this.context.locale) { return this.context.locale } return Locale.browserLocale() } timezone() { if (this.props.timezone) { return this.props.timezone } else if (this.context && this.context.timezone) { return this.context.timezone } return DateTime.browserTimeZone() } // date is returned as an ISO string, like 2021-09-14T22:00:00.000Z handleDayClick = (event: MouseEvent, { date }: { date: string }) => { if (this.props.onDateSelected) { const parsedDate = DateTime.parse(date, this.locale(), this.timezone()) this.props.onDateSelected(parsedDate.toISOString(), parsedDate, event) } } isDisabledDate(date: Moment) { const disabledDates = this.props.disabledDates if (!disabledDates) { return false } if (Array.isArray(disabledDates)) { for (const aDisabledDate of disabledDates) { if (date.isSame(aDisabledDate, 'day')) { return true } } return false } return disabledDates(date.toISOString()) } renderDefaultdays() { const { selectedDate } = this.props const { visibleMonth, today } = this.state // Sets it to the first local day of the week counting back from the start of the month. // Note that first day depends on the locale, e.g. it's Sunday in the US and // Monday in most of the EU. const currDate = DateTime.getFirstDayOfWeek( visibleMonth.clone().startOf('month') ) const arr: Moment[] = [] for (let i = 0; i < Calendar.DAY_COUNT; i++) { // This workaround is needed because moment's `.add({days: 1})` function has a bug that happens when the date added lands perfectly onto the DST cutoff, // in these cases adding 1 day results in 23 hours added instead, // so `moment.tz('2024-09-07T00:00:00', 'America/Santiago').add({days: 1})` results // in "Sat Sep 07 2024 23:00:00 GMT-0400" instead of "Sun Sep 08 2024 00:00:00 GMT-0400". // which would cause duplicate dates in the calendar. // More info on the bug: https://github.com/moment/moment/issues/4743 // Please note that this causes one hour of time difference in the affected timezones/dates and to // fully solve this bug we need to change to something like luxon which handles this properly if (currDate.clone().format('HH') === '23') { arr.push(currDate.clone().add({ hours: 1 })) } else { arr.push(currDate.clone()) } currDate.add({ days: 1 }) } return arr.map((date) => { const dateStr = date.toISOString() return ( {date.format('DD')} ) }) } render() { const passthroughProps = View.omitViewProps( omitProps(this.props, Calendar.allowedProps), Calendar ) return ( {this.renderHeader()} {this.renderBody()} ) } } export default Calendar export { Calendar }