Skip to content
141 changes: 101 additions & 40 deletions packages/@react-spectrum/s2/src/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,20 @@ import {
useSlottedContext
} from 'react-aria-components';
import {AriaCalendarGridProps} from '@react-aria/calendar';
import {baseColor, focusRing, lightDark, style} from '../style' with {type: 'macro'};
import {
CalendarDate,
getDayOfWeek,
startOfMonth
} from '@internationalized/date';
import ChevronLeftIcon from '../s2wf-icons/S2_Icon_ChevronLeft_20_N.svg';
import ChevronRightIcon from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg';
import {focusRing, lightDark, style} from '../style' with {type: 'macro'};
import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared';
import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
import {helpTextStyles} from './Field';
// @ts-ignore
import intlMessages from '../intl/*.json';
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react';
import React, {createContext, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react';
import {useDateFormatter, useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
import {useSpectrumContextProps} from './useSpectrumContextProps';

Expand Down Expand Up @@ -156,7 +156,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
},
outlineOffset: {
default: -2,
isToday: 2,
isSelected: {
selectionMode: {
single: 2,
Expand Down Expand Up @@ -184,10 +183,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
},
isPressed: 'gray-100',
isDisabled: 'transparent',
isToday: {
default: baseColor('gray-300'),
isDisabled: 'disabled'
},
isSelected: {
selectionMode: {
single: {
Expand Down Expand Up @@ -254,7 +249,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
},
forcedColors: {
default: 'transparent',
isToday: 'ButtonFace',
isHovered: 'Highlight',
isSelected: {
selectionMode: {
Expand Down Expand Up @@ -282,7 +276,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
isDisabled: 'disabled',
forcedColors: {
default: 'ButtonText',
isToday: 'ButtonFace',
isSelected: 'HighlightText',
isSelectionStart: 'HighlightText',
isSelectionEnd: 'HighlightText',
Expand All @@ -291,6 +284,21 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
}
});

const todayStyles = style({
position: 'absolute',
bottom: 4,
left: '50%',
transform: 'translateX(-50%)',
width: 4,
height: 4,
borderRadius: 'full',
backgroundColor: '[currentColor]',
display: {
default: 'none',
isToday: 'block'
}
});

const unavailableStyles = style({
position: 'absolute',
top: 'calc(50% - 1px)',
Expand All @@ -302,24 +310,35 @@ const unavailableStyles = style({
backgroundColor: '[currentColor]'
});

const selectionSpanStyles = style<{isInvalid?: boolean}>({
const selectionBackgroundStyles = style<{isInvalid?: boolean, isFirstDayInWeek?: boolean, isLastDayInWeek?: boolean, isSelectionStart?: boolean, isSelectionEnd?: boolean, isPreviousDayNotSelected?: boolean, isNextDayNotSelected?: boolean}>({
position: 'absolute',
zIndex: -1,
top: 0,
insetStart: 'calc(-1 * var(--selection-span) * (var(--cell-width) + var(--cell-gap) + var(--cell-gap)))',
insetEnd: 0,
insetStart: {
default: -4,
isFirstDayInWeek: 0,
isSelectionStart: 0,
isPreviousDayNotSelected: 0
},
insetEnd: {
default: -4,
isLastDayInWeek: 0,
isSelectionEnd: 0,
isNextDayNotSelected: 0
},
bottom: 0,
borderWidth: 2,
borderStyle: 'dashed',
borderColor: {
default: 'blue-800', // focus-indicator-color
isInvalid: 'negative-900',
forcedColors: {
default: 'ButtonText'
}
borderStartRadius: {
default: 'none',
isFirstDayInWeek: 'full',
isSelectionStart: 'full',
isPreviousDayNotSelected: 'full'
},
borderEndRadius: {
default: 'none',
isLastDayInWeek: 'full',
isSelectionEnd: 'full',
isNextDayNotSelected: 'full'
},
borderStartRadius: 'full',
borderEndRadius: 'full',
backgroundColor: {
default: 'blue-subtle',
isInvalid: 'negative-100',
Expand All @@ -330,6 +349,58 @@ const selectionSpanStyles = style<{isInvalid?: boolean}>({
forcedColorAdjust: 'none'
});

const selectionBorderStyles = style<{isInvalid?: boolean, isFirstDayInWeek?: boolean, isLastDayInWeek?: boolean, isSelectionStart?: boolean, isSelectionEnd?: boolean, isPreviousDayNotSelected?: boolean, isNextDayNotSelected?: boolean}>({
position: 'absolute',
zIndex: 1,
top: 0,
insetStart: {
default: -4,
isFirstDayInWeek: 0,
isSelectionStart: 0,
isPreviousDayNotSelected: 0
},
insetEnd: {
default: -4,
isLastDayInWeek: 0,
isSelectionEnd: 0,
isNextDayNotSelected: 0
},
bottom: 0,
borderStartWidth: {
default: 0,
isFirstDayInWeek: 1,
isSelectionStart: 1,
isPreviousDayNotSelected: 1
},
borderTopWidth: 1,
borderEndWidth: {
default: 0,
isLastDayInWeek: 1,
isSelectionEnd: 1,
isNextDayNotSelected: 1
},
borderBottomWidth: 1,
borderStyle: 'solid',
borderColor: {
default: 'blue-800', // focus-indicator-color
isInvalid: 'negative-900',
forcedColors: {
default: 'ButtonText'
}
},
borderStartRadius: {
default: 'none',
isFirstDayInWeek: 'full',
isSelectionStart: 'full',
isPreviousDayNotSelected: 'full'
},
borderEndRadius: {
default: 'none',
isLastDayInWeek: 'full',
isSelectionEnd: 'full',
isNextDayNotSelected: 'full'
}
});
/**
* Calendars display a grid of days in one or more months and allow users to select a single date.
*/
Expand Down Expand Up @@ -528,18 +599,14 @@ const CalendarCell = (props: Omit<CalendarCellProps, 'children'> & {firstDayOfWe
};

const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRangeSelection: boolean, state: CalendarState | RangeCalendarState, weekIndex: number, dayIndex: number, renderProps?: CalendarCellRenderProps, date: DateValue}): ReactElement => {
let {weekIndex, dayIndex, date, renderProps, state, isRangeSelection} = props;
let {getDatesInWeek} = state;
let {dayIndex, date, renderProps, state, isRangeSelection} = props;
let ref = useRef<HTMLDivElement>(null);
let {isUnavailable, formattedDate, isSelected, isSelectionStart, isSelectionEnd, isInvalid} = renderProps!;
// only apply the selection start/end styles if the start/end date is actually selectable (aka not unavailable)
// or if the range is invalid and thus we still want to show the styles even if the start/end date is an unavailable one
isSelectionStart = isSelectionStart && (!isUnavailable || isInvalid);
isSelectionEnd = isSelectionEnd && (!isUnavailable || isInvalid);

let startDate = startOfMonth(date);
let datesInWeek = getDatesInWeek(weekIndex, startDate);

let isDateInRange = (checkDate: CalendarDate) => {
if (!('highlightedRange' in state) || !state.highlightedRange) {
return state.isSelected(checkDate);
Expand All @@ -553,20 +620,12 @@ const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRange
return state.isSelected(checkDate);
};

// Starting from the current day, find the first day before it in the current week that is not selected.
// Then, the span of selected days is the current day minus the first unselected day.
let firstUnselectedInRangeInWeek = datesInWeek.slice(0, dayIndex + 1).reverse().findIndex((date, i) => {
return date && i > 0 && (!isDateInRange(date) || date.month !== props.date.month);
});

let selectionSpan = -1;
if (firstUnselectedInRangeInWeek > -1 && isSelected) {
selectionSpan = firstUnselectedInRangeInWeek - 1;
} else if (isSelected) {
selectionSpan = dayIndex;
}
let prevDay = date.subtract({days: 1});
let nextDay = date.add({days: 1});
let isFirstDayInWeek = dayIndex === 0;
let isLastDayInWeek = dayIndex === 6;
let isPreviousDayNotSelected = !prevDay || (!isDateInRange(prevDay) || prevDay.month !== props.date.month);
let isNextDayNotSelected = !nextDay || (!isDateInRange(nextDay) || nextDay.month !== props.date.month);

// when invalid, show background for all selected dates (including unavailable) to make continuous range appearance
// when valid, only show background for available selected dates
Expand All @@ -592,12 +651,14 @@ const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRange
ref={ref}
style={pressScale(ref, {})(renderProps!)}
className={cellInnerStyles({...renderProps!, isSelectionStart, isSelectionEnd, selectionMode: isRangeSelection ? 'range' : 'single'})}>
<div className={todayStyles(renderProps!)} role="presentation" />
<div>
{formattedDate}
</div>
{isUnavailable && <div className={unavailableStyles} role="presentation" />}
</div>
{isBackgroundStyleApplied && <div style={{'--selection-span': selectionSpan} as CSSProperties} className={selectionSpanStyles({isInvalid})} role="presentation" />}
{isBackgroundStyleApplied && <div className={selectionBackgroundStyles({isInvalid, isFirstDayInWeek, isLastDayInWeek, isSelectionStart, isSelectionEnd, isPreviousDayNotSelected, isNextDayNotSelected})} role="presentation" />}
{isBackgroundStyleApplied && <div className={selectionBorderStyles({isInvalid, isFirstDayInWeek, isLastDayInWeek, isSelectionStart, isSelectionEnd, isPreviousDayNotSelected, isNextDayNotSelected})} role="presentation" />}
</div>
);
};
Expand Down