package.src.components.CalendarMonth.CalendarMonth.tsx Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of react-core Show documentation
Show all versions of react-core Show documentation
This library provides a set of common React components for use with the PatternFly reference implementation.
The newest version!
import React, { useEffect } from 'react';
import { TextInput } from '../TextInput';
import { Button } from '../Button';
import { Select, SelectList, SelectOption } from '../Select';
import { MenuToggle, MenuToggleElement } from '../MenuToggle';
import { InputGroup, InputGroupItem } from '../InputGroup';
import AngleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-left-icon';
import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon';
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/CalendarMonth/calendar-month';
import { getUniqueId } from '../../helpers/util';
import { isValidDate } from '../../helpers/datetimeUtils';
export enum Weekday {
Sunday = 0,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
}
export interface CalendarMonthInlineProps {
/** Component wrapping the calendar month when used inline. Recommended to be 'article'. */
component?: keyof JSX.IntrinsicElements;
/** Title of the calendar rendered above the inline calendar month. Recommended to be a 'title' component. */
title?: React.ReactNode;
/** Id of the accessible label of the calendar month. Recommended to map to the title. */
ariaLabelledby?: string;
}
/** Additional properties that extend from and can be passed to the main component. These
* properties allow customizing the calendar formatting and aria-labels.
*/
export interface CalendarFormat {
/** Accessible label for the date cells. */
cellAriaLabel?: (date: Date) => string;
/** How to format days in buttons in table cells. */
dayFormat?: (date: Date) => React.ReactNode;
/** If using the default formatters which locale to use. Undefined defaults to current locale.
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#Locale_identification_and_negotiation
*/
locale?: string;
/** How to format days in header for screen readers. */
longWeekdayFormat?: (date: Date) => React.ReactNode;
/** How to format months in month select. */
monthFormat?: (date: Date) => React.ReactNode;
/** Accessible label for the previous month button. */
prevMonthAriaLabel?: string;
/** Accessible label for the next month button. */
nextMonthAriaLabel?: string;
/** Which date to start range styles from. */
rangeStart?: Date;
/** How to format week days in header. */
weekdayFormat?: (date: Date) => React.ReactNode;
/** Day of week that starts the week. 0 is Sunday, 6 is Saturday. */
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | Weekday;
/** Accessible label for the year input. */
yearInputAriaLabel?: string;
/** Props used to ensure accessibility when displaying the calendar month inline. */
inlineProps?: CalendarMonthInlineProps;
}
export interface CalendarProps extends CalendarFormat, Omit, 'onChange'> {
/** Additional classes to add to the outer div of the calendar month. */
className?: string;
/** Month/year to base other dates around. */
date?: Date;
/** Flag to set browser focus on the passed date. **/
isDateFocused?: boolean;
/** Callback when date is selected. */
onChange?: (event: React.MouseEvent, date: Date) => void;
/** Callback when month or year is changed. */
onMonthChange?: (
event?: React.MouseEvent | React.ChangeEvent | React.FormEvent,
newDate?: Date
) => void;
/** @hide Internal prop to allow pressing escape in select menu to not close popover. */
onSelectToggle?: (open: boolean) => void;
/** Functions that returns if a date is valid and selectable. */
validators?: ((date: Date) => boolean)[];
}
const buildCalendar = (year: number, month: number, weekStart: number, validators: ((date: Date) => boolean)[]) => {
const defaultDate = new Date(year, month);
const firstDayOfWeek = new Date(defaultDate);
firstDayOfWeek.setDate(firstDayOfWeek.getDate() - firstDayOfWeek.getDay() + weekStart);
// We will show a maximum of 6 weeks like Google calendar
// Assume we just want the numbers for now...
const calendarWeeks = [];
if (firstDayOfWeek.getMonth() === defaultDate.getMonth() && firstDayOfWeek.getDate() !== 1) {
firstDayOfWeek.setDate(firstDayOfWeek.getDate() - 7);
}
for (let i = 0; i < 6; i++) {
const week = [];
for (let j = 0; j < 7; j++) {
const date = new Date(firstDayOfWeek);
week.push({
date,
isValid: validators.every((validator) => validator(date))
});
firstDayOfWeek.setDate(firstDayOfWeek.getDate() + 1);
}
calendarWeeks.push(week);
if (firstDayOfWeek.getMonth() !== defaultDate.getMonth()) {
break;
}
}
return calendarWeeks;
};
const isSameDate = (d1: Date, d2: Date) =>
d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate();
const today = new Date();
/** The main calendar month component. */
export const CalendarMonth = ({
date: dateProp,
locale = undefined,
monthFormat = (date) => date.toLocaleDateString(locale, { month: 'long' }),
weekdayFormat = (date) => date.toLocaleDateString(locale, { weekday: 'narrow' }),
longWeekdayFormat = (date) => date.toLocaleDateString(locale, { weekday: 'long' }),
dayFormat = (date) => date.getDate(),
weekStart = 0, // Use the American Sunday as a default
onChange = () => {},
validators = [() => true],
className,
onSelectToggle = () => {},
onMonthChange = () => {},
rangeStart,
prevMonthAriaLabel = 'Previous month',
nextMonthAriaLabel = 'Next month',
yearInputAriaLabel = 'Select year',
cellAriaLabel,
isDateFocused = false,
inlineProps,
...props
}: CalendarProps) => {
const longMonths = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
.map((monthNum) => new Date(1990, monthNum))
.map(monthFormat);
const [isSelectOpen, setIsSelectOpen] = React.useState(false);
const getInitialDate = () => {
if (isValidDate(dateProp)) {
return dateProp;
}
if (isValidDate(rangeStart)) {
return rangeStart;
}
return today;
};
const initialDate = getInitialDate();
const [focusedDate, setFocusedDate] = React.useState(initialDate);
// Must be numeric given current header design
const yearFormat = (date: Date) => date.getFullYear();
//
const yearFormatted = yearFormat(focusedDate);
const [yearInput, setYearInput] = React.useState(yearFormatted.toString());
const [hoveredDate, setHoveredDate] = React.useState(new Date(focusedDate));
const focusRef = React.useRef();
const [hiddenMonthId] = React.useState(getUniqueId('hidden-month-span'));
const [shouldFocus, setShouldFocus] = React.useState(false);
const isValidated = (date: Date) => validators.every((validator) => validator(date));
const focusedDateValidated = isValidated(focusedDate);
useEffect(() => {
if (isValidDate(dateProp) && !isSameDate(focusedDate, dateProp)) {
setFocusedDate(dateProp);
} else if (!dateProp) {
setFocusedDate(today);
}
}, [dateProp]);
useEffect(() => {
// Calendar month should not be focused on page load
if ((shouldFocus || isDateFocused) && focusedDateValidated && focusRef.current) {
focusRef.current.focus();
}
}, [focusedDate, isDateFocused, focusedDateValidated, focusRef]);
const onMonthClick = (ev: React.MouseEvent, newDate: Date) => {
setFocusedDate(newDate);
setHoveredDate(newDate);
setShouldFocus(false);
onMonthChange(ev, newDate);
setYearInput(yearFormat(newDate).toString());
};
const onKeyDown = (ev: React.KeyboardEvent) => {
const newDate = new Date(focusedDate);
if (ev.key === 'ArrowUp') {
newDate.setDate(newDate.getDate() - 7);
} else if (ev.key === 'ArrowRight') {
newDate.setDate(newDate.getDate() + 1);
} else if (ev.key === 'ArrowDown') {
newDate.setDate(newDate.getDate() + 7);
} else if (ev.key === 'ArrowLeft') {
newDate.setDate(newDate.getDate() - 1);
}
if (newDate.getTime() !== focusedDate.getTime() && isValidated(newDate)) {
ev.preventDefault();
setFocusedDate(newDate);
setHoveredDate(newDate);
setShouldFocus(true);
}
};
const changeYear = (newYear: number) => changeMonth(focusedDate.getMonth(), newYear);
const changeMonth = (newMonth: number, newYear?: number) =>
new Date(newYear ?? focusedDate.getFullYear(), newMonth, 1);
const MIN_YEAR = 1900;
const MAX_YEAR = 2100;
const handleYearInputChange = (event: React.FormEvent, yearStr: string) => {
if (!/^\d{0,4}$/.test(yearStr)) {
return;
}
setYearInput(yearStr);
if (yearStr.length === 4) {
const yearNum = Number(yearStr);
if (yearNum >= MIN_YEAR && yearNum <= MAX_YEAR) {
const newDate = changeYear(yearNum);
setFocusedDate(newDate);
setHoveredDate(newDate);
setShouldFocus(false);
// We need to manually focus the year input in FireFox when the scroll buttons are clicked, as FireFox doesn't place focus automatically
(event.target as HTMLElement).focus();
onMonthChange(event, newDate);
} else {
setYearInput(yearFormatted.toString());
}
}
};
const addMonth = (toAdd: -1 | 1) => {
let newMonth = focusedDate.getMonth() + toAdd;
let newYear = focusedDate.getFullYear();
if (newMonth === -1) {
newMonth = 11;
newYear--;
} else if (newMonth === 12) {
newMonth = 0;
newYear++;
}
return changeMonth(newMonth, newYear);
};
const prevMonth = addMonth(-1);
const nextMonth = addMonth(1);
const focusedYear = focusedDate.getFullYear();
const focusedMonth = focusedDate.getMonth();
const calendar = React.useMemo(
() => buildCalendar(focusedYear, focusedMonth, weekStart, validators),
[focusedYear, focusedMonth, weekStart, validators]
);
if (!focusedDateValidated) {
const toFocus = calendar
.reduce((acc, cur) => [...acc, ...cur], [])
.filter(({ date, isValid }) => isValid && date.getMonth() === focusedMonth)
.map(({ date }) => ({ date, days: Math.abs(focusedDate.getTime() - date.getTime()) }))
.sort((o1, o2) => o1.days - o2.days)
.map(({ date }) => date)[0];
if (toFocus) {
setFocusedDate(toFocus);
setHoveredDate(toFocus);
}
}
const isHoveredDateValid = isValidated(hoveredDate);
const monthFormatted = monthFormat(focusedDate);
const calendarToRender = (
{calendar[0].map(({ date }, index) => (
{longWeekdayFormat(date)}
{weekdayFormat(date)}
))}
{calendar.map((week, index) => (
{week.map(({ date, isValid }, index) => {
const dayFormatted = dayFormat(date);
const isToday = isSameDate(date, today);
const isSelected = isValidDate(dateProp) && isSameDate(date, dateProp);
const isFocused = isSameDate(date, focusedDate);
const isAdjacentMonth = date.getMonth() !== focusedDate.getMonth();
const isRangeStart = isValidDate(rangeStart) && isSameDate(date, rangeStart);
let isInRange = false;
let isRangeEnd = false;
if (isValidDate(rangeStart) && isValidDate(dateProp)) {
isInRange = date > rangeStart && date < dateProp;
isRangeEnd = isSameDate(date, dateProp);
} else if (isValidDate(rangeStart) && isHoveredDateValid) {
if (hoveredDate > rangeStart || isSameDate(hoveredDate, rangeStart)) {
isInRange = date > rangeStart && date < hoveredDate;
isRangeEnd = isSameDate(date, hoveredDate);
}
// Don't handle focused dates before start dates for now.
// Core would likely need new styles
}
return (
);
})}
))}
);
if (inlineProps !== undefined) {
const Component = (inlineProps.component ? inlineProps.component : 'article') as any;
return (
{inlineProps.title}
{calendarToRender}
);
}
return calendarToRender;
};
CalendarMonth.displayName = 'CalendarMonth';