grommet/grommet

View on GitHub
src/js/components/Calendar/Calendar.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, {
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { ThemeContext } from 'styled-components';
import { AnnounceContext } from '../../contexts/AnnounceContext';
import { MessageContext } from '../../contexts/MessageContext';
import { defaultProps } from '../../default-props';

import { Box } from '../Box';
import { Button } from '../Button';
import { Header } from '../Header';
import { Heading } from '../Heading';
import { Keyboard } from '../Keyboard';
import { Text } from '../Text';

import { CalendarPropTypes } from './propTypes';
import {
  StyledCalendar,
  StyledDay,
  StyledDayContainer,
  StyledWeek,
  StyledWeeks,
  StyledWeeksContainer,
} from './StyledCalendar';
import {
  addDays,
  addMonths,
  betweenDates,
  daysApart,
  endOfMonth,
  handleOffset,
  sameDayOrAfter,
  sameDayOrBefore,
  startOfMonth,
  subtractDays,
  subtractMonths,
  withinDates,
} from './utils';
import { setHoursWithOffset } from '../../utils/dates';

const headingPadMap = {
  small: 'xsmall',
  medium: 'small',
  large: 'medium',
};

const getLocaleString = (value, locale) =>
  value?.toLocaleDateString(locale, {
    month: 'long',
    day: 'numeric',
    year: 'numeric',
  });

const currentlySelectedString = (value, locale) => {
  let selected;
  if (value instanceof Date) {
    selected = `Currently selected ${getLocaleString(value, locale)};`;
  } else if (value?.length) {
    selected = `Currently selected ${value.map((item) => {
      let dates;
      if (!Array.isArray(item)) {
        dates = `${getLocaleString(item, locale)}`;
      } else {
        const start =
          item[0] !== undefined ? getLocaleString(item[0], locale) : 'none';
        const end =
          item[1] !== undefined ? getLocaleString(item[1], locale) : 'none';
        dates = `${start} through ${end}`;
      }
      return dates;
    })}`;
  } else {
    selected = 'No date selected';
  }

  return selected;
};

// calendar value may be a single date, multiple dates, a range of dates
// supplied as ISOstrings.
const normalizeInput = (dateValue) => {
  let result;
  if (dateValue instanceof Date) {
    result = dateValue;
  }
  // date may be an empty string ''
  else if (typeof dateValue === 'string' && dateValue.length) {
    result = setHoursWithOffset(dateValue);
  } else if (Array.isArray(dateValue)) {
    result = dateValue.map((d) => normalizeInput(d));
  }
  return result;
};

const normalizeOutput = (dateValue, outputFormat) => {
  let result;

  const normalize = (value) => {
    let normalizedValue = value.toISOString();
    if (normalizedValue && outputFormat === 'no timezone') {
      [normalizedValue] = handleOffset(normalizedValue)
        .toISOString()
        .split('T');
    }
    return normalizedValue;
  };

  if (dateValue instanceof Date) {
    result = normalize(dateValue);
  } else if (typeof dateValue === 'undefined') {
    result = undefined;
  } else {
    result = dateValue.map((d) => normalizeOutput(d, outputFormat));
  }
  return result;
};

// format value to [[]] for internal functions
const normalizeRange = (value, activeDate) => {
  let range = value;
  if (range instanceof Date)
    range =
      activeDate === 'start' ? [[undefined, range]] : [[range, undefined]];
  else if (Array.isArray(range) && !Array.isArray(range[0])) range = [range];

  return range;
};

const getReference = (reference, value) => {
  let nextReference;
  if (value) {
    if (Array.isArray(value)) {
      if (value[0] instanceof Date) {
        [nextReference] = value;
      } else if (Array.isArray(value[0])) {
        nextReference = value[0][0] ? value[0][0] : value[0][1];
      } else {
        nextReference = new Date();
        nextReference.setHours(0, 0, 0, 0);
      }
    } else nextReference = value;
  } else if (reference) {
    nextReference = reference;
  } else {
    nextReference = new Date();
    nextReference.setHours(0, 0, 0, 0);
  }
  return nextReference;
};

const buildDisplayBounds = (reference, firstDayOfWeek) => {
  let start = new Date(reference);
  start.setDate(1); // first of month

  // In case Sunday is the first day of the month, and the user asked for Monday
  // to be the first day of the week, then we need to include Sunday and six
  // days prior.
  start =
    start.getDay() === 0 && firstDayOfWeek === 1
      ? (start = subtractDays(start, 6))
      : // beginning of week
        (start = subtractDays(start, start.getDay() - firstDayOfWeek));

  const end = addDays(start, 7 * 5 + 7); // 5 weeks to end of week
  return [start, end];
};

const disabledCalendarPreviousMonthButton = (date, reference, bounds) => {
  if (!bounds) return false;

  const lastBound = new Date(bounds[1]);

  return !sameDayOrBefore(lastBound, reference) && !betweenDates(date, bounds);
};

const disabledCalendarNextMonthButton = (date, reference, bounds) => {
  if (!bounds) return false;

  const firstBound = new Date(bounds[0]);

  return !sameDayOrAfter(firstBound, reference) && !betweenDates(date, bounds);
};

export const getOutputFormat = (dates) => {
  if (typeof dates === 'string' && dates?.indexOf('T') === -1) {
    return 'no timezone';
  }
  if (Array.isArray(dates)) {
    return getOutputFormat(dates[0]);
  }
  return 'date timezone';
};

const millisecondsPerYear = 31557600000;

const CalendarDayButton = (props) => <Button tabIndex={-1} plain {...props} />;

const CalendarDay = ({
  children,
  fill,
  size,
  isInRange,
  isSelected,
  otherMonth,
  buttonProps = {},
}) => (
  <StyledDayContainer role="gridcell" sizeProp={size} fillContainer={fill}>
    <CalendarDayButton fill={fill} {...buttonProps}>
      <StyledDay
        disabledProp={buttonProps.disabled}
        inRange={isInRange}
        otherMonth={otherMonth}
        isSelected={isSelected}
        sizeProp={size}
        fillContainer={fill}
      >
        {children}
      </StyledDay>
    </CalendarDayButton>
  </StyledDayContainer>
);

const CalendarCustomDay = ({ children, fill, size, buttonProps }) => {
  if (!buttonProps) {
    return (
      <StyledDayContainer role="gridcell" sizeProp={size} fillContainer={fill}>
        {children}
      </StyledDayContainer>
    );
  }

  return (
    <StyledDayContainer role="gridcell" sizeProp={size} fillContainer={fill}>
      <CalendarDayButton fill={fill} {...buttonProps}>
        {children}
      </CalendarDayButton>
    </StyledDayContainer>
  );
};

const Calendar = forwardRef(
  (
    {
      activeDate: activeDateProp,
      animate = true,
      bounds: boundsProp,
      children,
      date: dateProp,
      dates: datesProp,
      daysOfWeek,
      disabled,
      initialFocus, // internal only for DateInput
      fill,
      firstDayOfWeek = 0,
      header,
      locale = 'en-US',
      messages,
      onReference,
      onSelect,
      range,
      reference: referenceProp,
      showAdjacentDays = true,
      size = 'medium',
      timestamp: timestampProp,
      ...rest
    },
    ref,
  ) => {
    const theme = useContext(ThemeContext) || defaultProps.theme;
    const announce = useContext(AnnounceContext);
    const { format } = useContext(MessageContext);

    // when mousedown, we don't want to let Calendar set
    // active date to firstInMonth
    const [mouseDown, setMouseDown] = useState(false);
    const onMouseDown = () => setMouseDown(true);
    const onMouseUp = () => setMouseDown(false);
    useEffect(() => {
      document.addEventListener('mousedown', onMouseDown);
      document.addEventListener('mouseup', onMouseUp);
      return () => {
        document.removeEventListener('mousedown', onMouseDown);
        document.removeEventListener('mouseup', onMouseUp);
      };
    }, []);

    // set activeDate when caller changes it, allows us to change
    // it internally too
    const [activeDate, setActiveDate] = useState(
      dateProp && typeof dateProp === 'string' && range ? 'end' : 'start',
    );
    useEffect(() => {
      if (activeDateProp) setActiveDate(activeDateProp);
    }, [activeDateProp]);

    const [value, setValue] = useState(normalizeInput(dateProp || datesProp));
    useEffect(() => {
      const val = dateProp || datesProp;
      setValue(normalizeInput(val));
    }, [dateProp, datesProp]);

    const [reference, setReference] = useState(
      getReference(normalizeInput(referenceProp), value),
    );
    useEffect(() => {
      if (value) {
        setReference(getReference(normalizeInput(referenceProp), value));
      }
    }, [referenceProp, value]);

    const [outputFormat, setOutputFormat] = useState(
      getOutputFormat(dateProp || datesProp),
    );
    useEffect(() => {
      setOutputFormat(getOutputFormat(dateProp || datesProp));
    }, [dateProp, datesProp]);

    // normalize bounds
    const [bounds, setBounds] = useState(boundsProp);
    useEffect(() => {
      if (boundsProp) setBounds(boundsProp);
      else setBounds(undefined);
    }, [boundsProp]);

    // calculate the bounds we display based on the reference
    const [displayBounds, setDisplayBounds] = useState(
      buildDisplayBounds(reference, firstDayOfWeek),
    );
    const [targetDisplayBounds, setTargetDisplayBounds] = useState();
    const [slide, setSlide] = useState();
    const [animating, setAnimating] = useState();

    // When the reference changes, we need to update the displayBounds.
    // This is easy when we aren't animating. If we are animating,
    // we temporarily increase the displayBounds to be the union of the old
    // and new ones and set slide to drive the animation. We keep track
    // of where we are heading via targetDisplayBounds. When the animation
    // finishes, we prune displayBounds down to where we are headed and
    // clear the slide and targetDisplayBounds.
    useEffect(() => {
      const nextDisplayBounds = buildDisplayBounds(reference, firstDayOfWeek);

      // Checks if the difference between the current and next DisplayBounds is
      // greater than a year. If that's the case, calendar should update without
      // animation.
      if (
        nextDisplayBounds[0].getTime() !== displayBounds[0].getTime() &&
        nextDisplayBounds[1].getTime() !== displayBounds[1].getTime()
      ) {
        let diffBoundsAboveYear = false;
        if (nextDisplayBounds[0].getTime() < displayBounds[0].getTime()) {
          if (
            displayBounds[0].getTime() - nextDisplayBounds[0].getTime() >
            millisecondsPerYear
          ) {
            diffBoundsAboveYear = true;
          }
        } else if (
          nextDisplayBounds[1].getTime() > displayBounds[1].getTime()
        ) {
          if (
            nextDisplayBounds[1].getTime() - displayBounds[1].getTime() >
            millisecondsPerYear
          ) {
            diffBoundsAboveYear = true;
          }
        }
        if (!animate || diffBoundsAboveYear) {
          setDisplayBounds(nextDisplayBounds);
        } else {
          setTargetDisplayBounds(nextDisplayBounds);
        }
      }
    }, [animate, firstDayOfWeek, reference, displayBounds]);

    useEffect(() => {
      // if the reference timezone has changed (e.g., controlled component),
      // both ends of the displayBounds should inherit that new timestamp
      if (targetDisplayBounds) {
        if (
          targetDisplayBounds[0].getTime() < displayBounds[0].getTime() ||
          targetDisplayBounds[1].getTime() > displayBounds[1].getTime()
        ) {
          setDisplayBounds([targetDisplayBounds[0], targetDisplayBounds[1]]);
        }
        if (targetDisplayBounds[0].getTime() < displayBounds[0].getTime()) {
          // only animate if the duration is within a year
          if (
            displayBounds[0].getTime() - targetDisplayBounds[0].getTime() <
              millisecondsPerYear &&
            daysApart(displayBounds[0], targetDisplayBounds[0])
          ) {
            setSlide({
              direction: 'down',
              weeks: daysApart(displayBounds[0], targetDisplayBounds[0]) / 7,
            });
            setAnimating(true);
          }
        } else if (
          targetDisplayBounds[1].getTime() > displayBounds[1].getTime()
        ) {
          if (
            targetDisplayBounds[1].getTime() - displayBounds[1].getTime() <
              millisecondsPerYear &&
            daysApart(targetDisplayBounds[1], displayBounds[1])
          ) {
            setSlide({
              direction: 'up',
              weeks: daysApart(targetDisplayBounds[1], displayBounds[1]) / 7,
            });
            setAnimating(true);
          }
        }
        return undefined;
      }

      setSlide(undefined);
      return undefined;
    }, [animating, displayBounds, targetDisplayBounds]);

    // Last step in updating the displayBounds. Allows for pruning
    // displayBounds and cleaning up states to occur after animation.
    useEffect(() => {
      if (animating && targetDisplayBounds) {
        // Wait for animation to finish before cleaning up.
        const timer = setTimeout(
          () => {
            setDisplayBounds(targetDisplayBounds);
            setTargetDisplayBounds(undefined);
            setSlide(undefined);
            setAnimating(false);
          },
          400, // Empirically determined.
        );
        return () => clearTimeout(timer);
      }
      return undefined;
    }, [animating, targetDisplayBounds]);

    // We have to deal with reference being the end of a month with more
    // days than the month we are changing to. So, we always set reference
    // to the first of the month before changing the month.
    const previousMonth = useMemo(
      () => endOfMonth(subtractMonths(startOfMonth(reference), 1)),
      [reference],
    );
    const nextMonth = useMemo(
      () => startOfMonth(addMonths(startOfMonth(reference), 1)),
      [reference],
    );

    const daysRef = useRef();
    const [focus, setFocus] = useState();
    const [active, setActive] = useState();

    useEffect(() => {
      if (initialFocus === 'days') daysRef.current.focus();
    }, [initialFocus]);

    const handleReference = useCallback(
      (nextReference) => {
        setReference(nextReference);
        if (onReference) onReference(nextReference.toISOString());
      },
      [onReference],
    );

    const changeReference = useCallback(
      (nextReference) => {
        if (betweenDates(nextReference, bounds)) handleReference(nextReference);
      },
      [handleReference, bounds],
    );

    const changeCalendarMonth = (messageId, newMonth) => {
      handleReference(newMonth);

      announce(
        format({
          id: messageId,
          messages,
          values: {
            date: newMonth.toLocaleDateString(locale, {
              month: 'long',
              year: 'numeric',
            }),
          },
        }),
      );
    };

    const handleRange = useCallback(
      (selectedDate) => {
        let result;
        const priorRange = normalizeRange(value, activeDate);
        // deselect when date clicked was the start/end of the range
        if (selectedDate.getTime() === priorRange?.[0]?.[0]?.getTime()) {
          result = [[undefined, priorRange[0][1]]];
          setActiveDate('start');
        } else if (selectedDate.getTime() === priorRange?.[0]?.[1]?.getTime()) {
          result = [[priorRange[0][0], undefined]];
          setActiveDate('end');
        }
        // selecting start date
        else if (activeDate === 'start') {
          if (!priorRange) {
            result = [[selectedDate, undefined]];
          } else if (!priorRange[0][1]) {
            result = [[selectedDate, priorRange[0][1]]];
          } else if (selectedDate.getTime() < priorRange[0][1].getTime()) {
            result = [[selectedDate, priorRange[0][1]]];
          } else if (selectedDate.getTime() > priorRange[0][1].getTime()) {
            result = [[selectedDate, undefined]];
          }
          setActiveDate('end');
        }
        // selecting end date
        else if (!priorRange) {
          result = [[undefined, selectedDate]];
          setActiveDate('start');
        } else if (selectedDate.getTime() < priorRange[0][0].getTime()) {
          result = [[selectedDate, undefined]];
          setActiveDate('end');
        } else if (selectedDate.getTime() > priorRange[0][0].getTime()) {
          result = [[priorRange[0][0], selectedDate]];
          setActiveDate('start');
        }

        // If no dates selected, always return undefined; else format
        // result according to specified range value.
        if (result[0].includes(undefined)) {
          if (range === 'array') {
            result = !result[0][0] && !result[0][1] ? undefined : result;
          } else {
            result = result[0].find((d) => d !== undefined);
          }
        }
        setValue(result);
        return result;
      },
      [activeDate, value, range],
    );

    const selectDate = useCallback(
      (selectedDate) => {
        let nextValue;

        if (range || Array.isArray(value?.[0])) {
          nextValue = handleRange(selectedDate);
        } else {
          nextValue = selectedDate;
        }

        if (onSelect) {
          nextValue = normalizeOutput(nextValue, outputFormat);
          onSelect(nextValue);
        }
      },
      [handleRange, onSelect, outputFormat, range, value],
    );

    const onClick = (selectedDate) => {
      selectDate(selectedDate);
      announce(
        `Selected ${getLocaleString(selectedDate, locale)}`,
        'assertive',
      );
      // Chrome moves the focus indicator to this button. Set
      // the focus to the grid of days instead.
      daysRef.current.focus();
      setActive(selectedDate);
    };

    const renderCalendarHeader = () => {
      const PreviousIcon =
        size === 'small'
          ? theme.calendar.icons.small.previous
          : theme.calendar.icons.previous;

      const NextIcon =
        size === 'small'
          ? theme.calendar.icons.small.next
          : theme.calendar.icons.next;

      const monthAndYear = reference.toLocaleDateString(locale, {
        month: 'long',
        year: 'numeric',
      });

      return (
        <Box direction="row" justify="between" align="center">
          <Header flex pad={{ horizontal: headingPadMap[size] || 'small' }}>
            {theme.calendar[size]?.title ? (
              <Text {...theme.calendar[size].title}>{monthAndYear}</Text>
            ) : (
              // theme.calendar.heading.level should be removed in v3 of grommet
              // theme.calendar[size].title should be used instead
              <Heading
                level={
                  size === 'small'
                    ? (theme.calendar.heading &&
                        theme.calendar.heading.level) ||
                      4
                    : ((theme.calendar.heading &&
                        theme.calendar.heading.level) ||
                        4) - 1
                }
                size={size}
                margin="none"
                overflowWrap="normal"
              >
                {monthAndYear}
              </Heading>
            )}
          </Header>
          <Box flex={false} direction="row" align="center">
            <Button
              a11yTitle={format({
                id: 'calendar.previous',
                messages,
                values: {
                  date: previousMonth.toLocaleDateString(locale, {
                    month: 'long',
                    year: 'numeric',
                  }),
                },
              })}
              icon={<PreviousIcon size={size !== 'small' ? size : undefined} />}
              disabled={disabledCalendarPreviousMonthButton(
                previousMonth,
                reference,
                bounds,
              )}
              onClick={() =>
                changeCalendarMonth('calendar.previousMove', previousMonth)
              }
            />
            <Button
              a11yTitle={format({
                id: 'calendar.next',
                messages,
                values: {
                  date: nextMonth.toLocaleDateString(locale, {
                    month: 'long',
                    year: 'numeric',
                  }),
                },
              })}
              icon={<NextIcon size={size !== 'small' ? size : undefined} />}
              disabled={disabledCalendarNextMonthButton(
                nextMonth,
                reference,
                bounds,
              )}
              onClick={() =>
                changeCalendarMonth('calendar.nextMove', nextMonth)
              }
            />
          </Box>
        </Box>
      );
    };

    const renderDaysOfWeek = () => {
      let day = new Date(displayBounds[0]);
      const days = [];
      while (days.length < 7) {
        days.push(
          <StyledDayContainer
            role="gridcell"
            key={days.length}
            sizeProp={size}
            fillContainer={fill}
          >
            <StyledDay otherMonth sizeProp={size} fillContainer={fill}>
              {day.toLocaleDateString(locale, { weekday: 'narrow' })}
            </StyledDay>
          </StyledDayContainer>,
        );
        day = addDays(day, 1);
      }
      return <StyledWeek role="row">{days}</StyledWeek>;
    };

    const weeks = [];
    let day = new Date(displayBounds[0]);
    let days;
    let firstDayInMonth;
    let blankWeek = false;

    while (day.getTime() < displayBounds[1].getTime()) {
      if (day.getDay() === firstDayOfWeek) {
        if (days) {
          weeks.push(
            <StyledWeek role="row" key={day.getTime()} fillContainer={fill}>
              {days}
            </StyledWeek>,
          );
        }
        days = [];
      }

      const otherMonth = day.getMonth() !== reference.getMonth();
      if (!showAdjacentDays && otherMonth) {
        days.push(
          <StyledDayContainer
            key={day.getTime()}
            sizeProp={size}
            fillContainer={fill}
          >
            <StyledDay sizeProp={size} fillContainer={fill} />
          </StyledDayContainer>,
        );

        if (
          weeks.length === 5 &&
          /* If the length days array is less than the current getDate()
          we know that all days in the array are from the next month. */
          days.length < day.getDate()
        ) {
          blankWeek = true;
        }
      } else if (
        /* Do not show adjacent days in 6th row if all days
        fall in the next month */
        showAdjacentDays === 'trim' &&
        otherMonth &&
        weeks.length === 5 &&
        /* If the length days array is less than the current getDate()
        we know that all days in the array are from the next month. */
        days.length < day.getDate()
      ) {
        blankWeek = true;
        days.push(
          <StyledDayContainer
            key={day.getTime()}
            sizeProp={size}
            fillContainer={fill}
          >
            <StyledDay sizeProp={size} fillContainer={fill} />
          </StyledDayContainer>,
        );
      } else {
        const dateObject = day;
        // this.dayRefs[dateObject] = React.createRef();
        let selected = false;
        let inRange = false;

        const selectedState = withinDates(
          day,
          range ? normalizeRange(value, activeDate) : value,
        );
        if (selectedState === 2) {
          selected = true;
        } else if (selectedState === 1) {
          inRange = true;
        }
        const dayDisabled =
          withinDates(day, normalizeInput(disabled)) ||
          (bounds && !betweenDates(day, normalizeInput(bounds)));
        if (
          !firstDayInMonth &&
          !dayDisabled &&
          day.getMonth() === reference.getMonth()
        ) {
          firstDayInMonth = dateObject;
        }

        if (!children) {
          days.push(
            <CalendarDay
              key={day.getTime()}
              buttonProps={{
                a11yTitle: day.toDateString(),
                active: active && active.getTime() === day.getTime(),
                disabled: dayDisabled && !!dayDisabled,
                onClick: () => onClick(dateObject),
                onMouseOver: () => setActive(dateObject),
                onMouseOut: () => setActive(undefined),
              }}
              isInRange={inRange}
              isSelected={selected}
              otherMonth={day.getMonth() !== reference.getMonth()}
              size={size}
              fill={fill}
            >
              {day.getDate()}
            </CalendarDay>,
          );
        } else {
          days.push(
            <CalendarCustomDay
              key={day.getTime()}
              buttonProps={
                onSelect
                  ? {
                      a11yTitle: day.toDateString(),
                      active: active && active.getTime() === day.getTime(),
                      disabled: dayDisabled && !!dayDisabled,
                      onClick: () => onClick(dateObject),
                      onMouseOver: () => setActive(dateObject),
                      onMouseOut: () => setActive(undefined),
                    }
                  : null
              }
              size={size}
              fill={fill}
            >
              {children({
                date: day,
                day: day.getDate(),
                isInRange: inRange,
                isSelected: selected,
              })}
            </CalendarCustomDay>,
          );
        }
      }
      day = addDays(day, 1);
    }
    weeks.push(
      <StyledWeek
        // if a week contains only blank days, for screen reader accessibility
        // we don't want to set role="row"
        role={!blankWeek ? 'row' : undefined}
        key={day.getTime()}
        fillContainer={fill}
      >
        {days}
      </StyledWeek>,
    );

    return (
      <StyledCalendar ref={ref} sizeProp={size} fillContainer={fill} {...rest}>
        <Box fill={fill}>
          {header
            ? header({
                date: reference,
                locale,
                onPreviousMonth: () => {
                  changeReference(previousMonth);
                  announce(
                    format({
                      id: 'calendar.previous',
                      messages,
                      values: {
                        date: previousMonth.toLocaleDateString(locale, {
                          month: 'long',
                          year: 'numeric',
                        }),
                      },
                    }),
                  );
                },
                onNextMonth: () => {
                  changeReference(nextMonth);
                  announce(
                    format({
                      id: 'calendar.next',
                      messages,
                      values: {
                        date: nextMonth.toLocaleDateString(locale, {
                          month: 'long',
                          year: 'numeric',
                        }),
                      },
                    }),
                  );
                },
                previousInBound: betweenDates(previousMonth, bounds),
                nextInBound: betweenDates(nextMonth, bounds),
              })
            : renderCalendarHeader(previousMonth, nextMonth)}
          {daysOfWeek && renderDaysOfWeek()}
          <Keyboard
            onEnter={() => (active !== undefined ? onClick(active) : undefined)}
            onUp={(event) => {
              event.preventDefault();
              event.stopPropagation(); // so the page doesn't scroll
              setActive(addDays(active, -7));
              if (!betweenDates(addDays(active, -7), displayBounds)) {
                changeReference(addDays(active, -7));
              }
            }}
            onDown={(event) => {
              event.preventDefault();
              event.stopPropagation(); // so the page doesn't scroll
              setActive(addDays(active, 7));
              if (!betweenDates(addDays(active, 7), displayBounds)) {
                changeReference(active);
              }
            }}
            onLeft={() => {
              setActive(addDays(active, -1));
              if (!betweenDates(addDays(active, -1), displayBounds)) {
                changeReference(active);
              }
            }}
            onRight={() => {
              setActive(addDays(active, 1));
              if (!betweenDates(addDays(active, 2), displayBounds)) {
                changeReference(active);
              }
            }}
          >
            <StyledWeeksContainer
              tabIndex={0}
              role="grid"
              aria-label={`${reference.toLocaleDateString(locale, {
                month: 'long',
                year: 'numeric',
              })}; ${currentlySelectedString(value, locale)}`}
              ref={daysRef}
              sizeProp={size}
              fillContainer={fill}
              focus={focus}
              onFocus={() => {
                setFocus(true);
                // caller focused onto Calendar via keyboard
                if (!mouseDown) {
                  setActive(new Date(firstDayInMonth));
                }
              }}
              onBlur={() => {
                setFocus(false);
                setActive(undefined);
              }}
            >
              <StyledWeeks slide={slide} sizeProp={size} fillContainer={fill}>
                {weeks}
              </StyledWeeks>
            </StyledWeeksContainer>
          </Keyboard>
        </Box>
      </StyledCalendar>
    );
  },
);

Calendar.displayName = 'Calendar';
Calendar.propTypes = CalendarPropTypes;

export { Calendar };