Skip to content

Commit 4e6456b

Browse files
authored
chore: calendar new today indicator (#9591)
* chore: calendar new today indicator * fix lint * simplify css and put selection hover between background and border * fix unavailable dates * fix to match expectations in chromatic * adjust padding * update remaining tokens and setup error message * Apply suggestions from code review Co-authored-by: Robert Snow <snowystinger@gmail.com>
1 parent 07214be commit 4e6456b

3 files changed

Lines changed: 130 additions & 51 deletions

File tree

packages/@react-spectrum/s2/src/Calendar.tsx

Lines changed: 114 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@ import {
3636
useSlottedContext
3737
} from 'react-aria-components';
3838
import {AriaCalendarGridProps} from '@react-aria/calendar';
39-
import {baseColor, focusRing, lightDark, style} from '../style' with {type: 'macro'};
4039
import {
4140
CalendarDate,
4241
getDayOfWeek,
4342
startOfMonth
4443
} from '@internationalized/date';
4544
import ChevronLeftIcon from '../s2wf-icons/S2_Icon_ChevronLeft_20_N.svg';
4645
import ChevronRightIcon from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg';
46+
import {focusRing, lightDark, style} from '../style' with {type: 'macro'};
4747
import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared';
4848
import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
4949
import {helpTextStyles} from './Field';
5050
// @ts-ignore
5151
import intlMessages from '../intl/*.json';
52-
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react';
52+
import React, {createContext, ForwardedRef, forwardRef, Fragment, PropsWithChildren, ReactElement, ReactNode, useContext, useMemo, useRef} from 'react';
5353
import {useDateFormatter, useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
5454
import {useSpectrumContextProps} from './useSpectrumContextProps';
5555

@@ -135,7 +135,10 @@ const cellStyles = style({
135135
default: 2,
136136
isFirstWeek: 0
137137
},
138-
paddingBottom: 2,
138+
paddingBottom: {
139+
default: 2,
140+
isLastWeek: 0
141+
},
139142
position: 'relative',
140143
width: 32,
141144
height: 32,
@@ -156,7 +159,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
156159
},
157160
outlineOffset: {
158161
default: -2,
159-
isToday: 2,
160162
isSelected: {
161163
selectionMode: {
162164
single: 2,
@@ -184,10 +186,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
184186
},
185187
isPressed: 'gray-100',
186188
isDisabled: 'transparent',
187-
isToday: {
188-
default: baseColor('gray-300'),
189-
isDisabled: 'disabled'
190-
},
191189
isSelected: {
192190
selectionMode: {
193191
single: {
@@ -254,7 +252,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
254252
},
255253
forcedColors: {
256254
default: 'transparent',
257-
isToday: 'ButtonFace',
258255
isHovered: 'Highlight',
259256
isSelected: {
260257
selectionMode: {
@@ -282,7 +279,6 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
282279
isDisabled: 'disabled',
283280
forcedColors: {
284281
default: 'ButtonText',
285-
isToday: 'ButtonFace',
286282
isSelected: 'HighlightText',
287283
isSelectionStart: 'HighlightText',
288284
isSelectionEnd: 'HighlightText',
@@ -291,6 +287,21 @@ const cellInnerStyles = style<CalendarCellRenderProps & {selectionMode: 'single'
291287
}
292288
});
293289

290+
const todayStyles = style({
291+
position: 'absolute',
292+
bottom: 4,
293+
left: '50%',
294+
transform: 'translateX(-50%)',
295+
width: 4,
296+
height: 4,
297+
borderRadius: 'full',
298+
backgroundColor: '[currentColor]',
299+
display: {
300+
default: 'none',
301+
isToday: 'block'
302+
}
303+
});
304+
294305
const unavailableStyles = style({
295306
position: 'absolute',
296307
top: 'calc(50% - 1px)',
@@ -302,24 +313,35 @@ const unavailableStyles = style({
302313
backgroundColor: '[currentColor]'
303314
});
304315

305-
const selectionSpanStyles = style<{isInvalid?: boolean}>({
316+
const selectionBackgroundStyles = style<{isInvalid?: boolean, isFirstDayInWeek?: boolean, isLastDayInWeek?: boolean, isSelectionStart?: boolean, isSelectionEnd?: boolean, isPreviousDayNotSelected?: boolean, isNextDayNotSelected?: boolean}>({
306317
position: 'absolute',
307318
zIndex: -1,
308319
top: 0,
309-
insetStart: 'calc(-1 * var(--selection-span) * (var(--cell-width) + var(--cell-gap) + var(--cell-gap)))',
310-
insetEnd: 0,
320+
insetStart: {
321+
default: -4,
322+
isFirstDayInWeek: 0,
323+
isSelectionStart: 0,
324+
isPreviousDayNotSelected: 0
325+
},
326+
insetEnd: {
327+
default: -4,
328+
isLastDayInWeek: 0,
329+
isSelectionEnd: 0,
330+
isNextDayNotSelected: 0
331+
},
311332
bottom: 0,
312-
borderWidth: 2,
313-
borderStyle: 'dashed',
314-
borderColor: {
315-
default: 'blue-800', // focus-indicator-color
316-
isInvalid: 'negative-900',
317-
forcedColors: {
318-
default: 'ButtonText'
319-
}
333+
borderStartRadius: {
334+
default: 'none',
335+
isFirstDayInWeek: 'full',
336+
isSelectionStart: 'full',
337+
isPreviousDayNotSelected: 'full'
338+
},
339+
borderEndRadius: {
340+
default: 'none',
341+
isLastDayInWeek: 'full',
342+
isSelectionEnd: 'full',
343+
isNextDayNotSelected: 'full'
320344
},
321-
borderStartRadius: 'full',
322-
borderEndRadius: 'full',
323345
backgroundColor: {
324346
default: 'blue-subtle',
325347
isInvalid: 'negative-100',
@@ -330,6 +352,58 @@ const selectionSpanStyles = style<{isInvalid?: boolean}>({
330352
forcedColorAdjust: 'none'
331353
});
332354

355+
const selectionBorderStyles = style<{isInvalid?: boolean, isFirstDayInWeek?: boolean, isLastDayInWeek?: boolean, isSelectionStart?: boolean, isSelectionEnd?: boolean, isPreviousDayNotSelected?: boolean, isNextDayNotSelected?: boolean}>({
356+
position: 'absolute',
357+
zIndex: 1,
358+
top: 0,
359+
insetStart: {
360+
default: -4,
361+
isFirstDayInWeek: 0,
362+
isSelectionStart: 0,
363+
isPreviousDayNotSelected: 0
364+
},
365+
insetEnd: {
366+
default: -4,
367+
isLastDayInWeek: 0,
368+
isSelectionEnd: 0,
369+
isNextDayNotSelected: 0
370+
},
371+
bottom: 0,
372+
borderStartWidth: {
373+
default: 0,
374+
isFirstDayInWeek: 1,
375+
isSelectionStart: 1,
376+
isPreviousDayNotSelected: 1
377+
},
378+
borderTopWidth: 1,
379+
borderEndWidth: {
380+
default: 0,
381+
isLastDayInWeek: 1,
382+
isSelectionEnd: 1,
383+
isNextDayNotSelected: 1
384+
},
385+
borderBottomWidth: 1,
386+
borderStyle: 'solid',
387+
borderColor: {
388+
default: 'blue-800', // focus-indicator-color
389+
isInvalid: 'negative-900',
390+
forcedColors: {
391+
default: 'ButtonText'
392+
}
393+
},
394+
borderStartRadius: {
395+
default: 'none',
396+
isFirstDayInWeek: 'full',
397+
isSelectionStart: 'full',
398+
isPreviousDayNotSelected: 'full'
399+
},
400+
borderEndRadius: {
401+
default: 'none',
402+
isLastDayInWeek: 'full',
403+
isSelectionEnd: 'full',
404+
isNextDayNotSelected: 'full'
405+
}
406+
});
333407
/**
334408
* Calendars display a grid of days in one or more months and allow users to select a single date.
335409
*/
@@ -508,38 +582,36 @@ const CalendarCell = (props: Omit<CalendarCellProps, 'children'> & {firstDayOfWe
508582
let {locale} = useLocale();
509583
let firstDayOfWeek = props.firstDayOfWeek;
510584
// Calculate the day and week index based on the date.
511-
let {dayIndex, weekIndex} = useWeekAndDayIndices(props.date, locale, firstDayOfWeek);
585+
let {dayIndex, weekIndex, lastWeekIndex} = useWeekAndDayIndices(props.date, locale, firstDayOfWeek);
512586

513587
let calendarStateContext = useContext(CalendarStateContext);
514588
let rangeCalendarStateContext = useContext(RangeCalendarStateContext);
515589
let state = (calendarStateContext ?? rangeCalendarStateContext)!;
516590

591+
517592
let isFirstWeek = weekIndex === 0;
593+
let isLastWeek = weekIndex === lastWeekIndex;
518594
let isFirstChild = dayIndex === 0;
519595
let isLastChild = dayIndex === 6;
520596

521597
return (
522598
<AriaCalendarCell
523599
date={props.date}
524-
className={(renderProps) => cellStyles({...renderProps, isFirstChild, isLastChild, isFirstWeek})}>
600+
className={(renderProps) => cellStyles({...renderProps, isFirstChild, isLastChild, isFirstWeek, isLastWeek})}>
525601
{(renderProps) => <CalendarCellInner {...props} weekIndex={weekIndex} dayIndex={dayIndex} state={state} isRangeSelection={!!rangeCalendarStateContext} renderProps={renderProps} />}
526602
</AriaCalendarCell>
527603
);
528604
};
529605

530606
const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRangeSelection: boolean, state: CalendarState | RangeCalendarState, weekIndex: number, dayIndex: number, renderProps?: CalendarCellRenderProps, date: DateValue}): ReactElement => {
531-
let {weekIndex, dayIndex, date, renderProps, state, isRangeSelection} = props;
532-
let {getDatesInWeek} = state;
607+
let {dayIndex, date, renderProps, state, isRangeSelection} = props;
533608
let ref = useRef<HTMLDivElement>(null);
534609
let {isUnavailable, formattedDate, isSelected, isSelectionStart, isSelectionEnd, isInvalid} = renderProps!;
535610
// only apply the selection start/end styles if the start/end date is actually selectable (aka not unavailable)
536611
// 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
537612
isSelectionStart = isSelectionStart && (!isUnavailable || isInvalid);
538613
isSelectionEnd = isSelectionEnd && (!isUnavailable || isInvalid);
539614

540-
let startDate = startOfMonth(date);
541-
let datesInWeek = getDatesInWeek(weekIndex, startDate);
542-
543615
let isDateInRange = (checkDate: CalendarDate) => {
544616
if (!('highlightedRange' in state) || !state.highlightedRange) {
545617
return state.isSelected(checkDate);
@@ -553,20 +625,12 @@ const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRange
553625
return state.isSelected(checkDate);
554626
};
555627

556-
// Starting from the current day, find the first day before it in the current week that is not selected.
557-
// Then, the span of selected days is the current day minus the first unselected day.
558-
let firstUnselectedInRangeInWeek = datesInWeek.slice(0, dayIndex + 1).reverse().findIndex((date, i) => {
559-
return date && i > 0 && (!isDateInRange(date) || date.month !== props.date.month);
560-
});
561-
562-
let selectionSpan = -1;
563-
if (firstUnselectedInRangeInWeek > -1 && isSelected) {
564-
selectionSpan = firstUnselectedInRangeInWeek - 1;
565-
} else if (isSelected) {
566-
selectionSpan = dayIndex;
567-
}
568628
let prevDay = date.subtract({days: 1});
569629
let nextDay = date.add({days: 1});
630+
let isFirstDayInWeek = dayIndex === 0;
631+
let isLastDayInWeek = dayIndex === 6;
632+
let isPreviousDayNotSelected = !prevDay || (!isDateInRange(prevDay) || prevDay.month !== props.date.month);
633+
let isNextDayNotSelected = !nextDay || (!isDateInRange(nextDay) || nextDay.month !== props.date.month);
570634

571635
// when invalid, show background for all selected dates (including unavailable) to make continuous range appearance
572636
// when valid, only show background for available selected dates
@@ -592,12 +656,14 @@ const CalendarCellInner = (props: Omit<CalendarCellProps, 'children'> & {isRange
592656
ref={ref}
593657
style={pressScale(ref, {})(renderProps!)}
594658
className={cellInnerStyles({...renderProps!, isSelectionStart, isSelectionEnd, selectionMode: isRangeSelection ? 'range' : 'single'})}>
659+
<div className={todayStyles(renderProps!)} role="presentation" />
595660
<div>
596661
{formattedDate}
597662
</div>
598663
{isUnavailable && <div className={unavailableStyles} role="presentation" />}
599664
</div>
600-
{isBackgroundStyleApplied && <div style={{'--selection-span': selectionSpan} as CSSProperties} className={selectionSpanStyles({isInvalid})} role="presentation" />}
665+
{isBackgroundStyleApplied && <div className={selectionBackgroundStyles({isInvalid, isFirstDayInWeek, isLastDayInWeek, isSelectionStart, isSelectionEnd, isPreviousDayNotSelected, isNextDayNotSelected})} role="presentation" />}
666+
{isBackgroundStyleApplied && <div className={selectionBorderStyles({isInvalid, isFirstDayInWeek, isLastDayInWeek, isSelectionStart, isSelectionEnd, isPreviousDayNotSelected, isNextDayNotSelected})} role="presentation" />}
601667
</div>
602668
);
603669
};
@@ -616,7 +682,7 @@ function useWeekAndDayIndices(
616682
locale: string,
617683
firstDayOfWeek?: DayOfWeek
618684
) {
619-
let {dayIndex, weekIndex} = useMemo(() => {
685+
let result = useMemo(() => {
620686
// Get the day index within the week (0-6)
621687
const dayIndex = getDayOfWeek(date, locale, firstDayOfWeek);
622688

@@ -628,12 +694,15 @@ function useWeekAndDayIndices(
628694
const dayOfMonth = date.day;
629695

630696
const weekIndex = Math.floor((dayOfMonth + monthStartDayOfWeek - 1) / 7);
697+
const lastDayOfMonth = startOfMonth(date).add({months: 1}).subtract({days: 1});
698+
const lastWeekIndex = Math.floor((lastDayOfMonth.day + monthStartDayOfWeek - 1) / 7);
631699

632700
return {
633701
weekIndex,
702+
lastWeekIndex,
634703
dayIndex
635704
};
636705
}, [date, locale, firstDayOfWeek]);
637706

638-
return {dayIndex, weekIndex};
707+
return result;
639708
}

packages/@react-spectrum/s2/src/DatePicker.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export interface DatePickerProps<T extends DateValue> extends
5757
* The maximum number of months to display at once in the calendar popover, if screen space permits.
5858
* @default 1
5959
*/
60-
maxVisibleMonths?: number
60+
maxVisibleMonths?: number,
61+
/**
62+
* The error message to display when the calendar is invalid.
63+
*/
64+
errorMessage?: ReactNode
6165
}
6266

6367
export const DatePickerContext = createContext<ContextValue<Partial<DatePickerProps<any>>, HTMLDivElement>>(null);
@@ -208,7 +212,8 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function
208212
<CalendarPopover shouldFlip={props.shouldFlip}>
209213
<Calendar
210214
visibleMonths={maxVisibleMonths}
211-
createCalendar={createCalendar} />
215+
createCalendar={createCalendar}
216+
errorMessage={errorMessage} />
212217
{showTimeField && (
213218
<div className={style({display: 'flex', gap: 16, contain: 'inline-size'})}>
214219
<TimeField
@@ -249,7 +254,7 @@ export function CalendarPopover(props: Omit<PopoverProps, 'children'> & {childre
249254
<div
250255
className={style({
251256
paddingX: 16,
252-
paddingY: 32,
257+
paddingY: 24,
253258
overflow: 'auto',
254259
display: 'flex',
255260
flexDirection: 'column',

packages/@react-spectrum/s2/src/DateRangePicker.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
PopoverProps
2020
} from 'react-aria-components';
2121
import {CalendarButton, CalendarPopover, timeField} from './DatePicker';
22-
import {createContext, forwardRef, ReactElement, Ref, useContext, useState} from 'react';
22+
import {createContext, forwardRef, ReactElement, ReactNode, Ref, useContext, useState} from 'react';
2323
import {DateInput, DateInputContainer, InvalidIndicator} from './DateField';
2424
import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
2525
import {FieldGroup, FieldLabel, HelpText} from './Field';
@@ -49,7 +49,11 @@ export interface DateRangePickerProps<T extends DateValue> extends
4949
* The maximum number of months to display at once in the calendar popover, if screen space permits.
5050
* @default 1
5151
*/
52-
maxVisibleMonths?: number
52+
maxVisibleMonths?: number,
53+
/**
54+
* The error message to display when the calendar is invalid.
55+
*/
56+
errorMessage?: ReactNode
5357
}
5458

5559
export const DateRangePickerContext = createContext<ContextValue<Partial<DateRangePickerProps<any>>, HTMLDivElement>>(null);
@@ -148,7 +152,8 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func
148152
<CalendarPopover shouldFlip={props.shouldFlip}>
149153
<RangeCalendar
150154
visibleMonths={maxVisibleMonths}
151-
createCalendar={createCalendar} />
155+
createCalendar={createCalendar}
156+
errorMessage={errorMessage} />
152157
{showTimeField && (
153158
<div className={style({display: 'flex', gap: 16, contain: 'inline-size', marginTop: 24})}>
154159
<TimeField

0 commit comments

Comments
 (0)