Skip to content

feat: S2 DateField/DatePicker/Calendar #8428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c143634
feat: S2 DateField/DatePicker/Calendar
snowystinger Jun 19, 2025
3b4f669
fix lint
snowystinger Jun 19, 2025
6401de3
start calendar
snowystinger Jun 19, 2025
7bbdd47
Style calendar and fix some datepicker styles
snowystinger Jun 20, 2025
0716d72
changing ring size
snowystinger Jun 23, 2025
b67342f
fix hover
snowystinger Jun 23, 2025
beb5265
revert outline size change
snowystinger Jun 23, 2025
fb693ab
add range calendar
snowystinger Jun 23, 2025
1ab4699
fix yarn lock
snowystinger Jun 23, 2025
f51db9c
fix cell styles for different size ring and gap
snowystinger Jun 24, 2025
b1d5752
Merge branch 'main' into s2-datepicker
snowystinger Jun 24, 2025
c8e2ef2
add spectrum context
snowystinger Jun 24, 2025
eb5c4ff
Add DateRangePicker
snowystinger Jun 24, 2025
aa71a83
add comment
snowystinger Jun 24, 2025
8b864c3
fix test
snowystinger Jun 24, 2025
0cbd107
Add TimeField and aria labels
snowystinger Jun 24, 2025
559c1bc
add press scaling
snowystinger Jun 26, 2025
0f697a8
Merge branch 'main' into s2-datepicker
snowystinger Jun 26, 2025
90332c7
fix non-contiguous ranges styles
snowystinger Jun 26, 2025
6fbf922
fix lint
snowystinger Jun 26, 2025
a82e047
remove explicit modules
snowystinger Jun 26, 2025
3db69f6
Merge branch 'main' into s2-datepicker
snowystinger Jun 27, 2025
356148a
Add time fields to date pickers
snowystinger Jun 27, 2025
b0f6cac
fix buttons in date picker popover
snowystinger Jun 27, 2025
eb9685a
remove console log
snowystinger Jun 27, 2025
6da6c82
fix storybook intermittent crash from implicit actions, scrolling
snowystinger Jun 27, 2025
5ee804b
fix accessibility violation
snowystinger Jun 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@react-aria/calendar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {useCalendar} from './useCalendar';
export {useRangeCalendar} from './useRangeCalendar';
export {useCalendarGrid} from './useCalendarGrid';
export {useCalendarCell} from './useCalendarCell';
export {getEraFormat} from './utils';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another way to do this? see RangeCalendar implementation for use


export type {AriaCalendarProps, AriaRangeCalendarProps, CalendarProps, DateValue, RangeCalendarProps} from '@react-types/calendar';
export type {CalendarAria} from './useCalendarBase';
Expand Down
5 changes: 4 additions & 1 deletion packages/@react-spectrum/s2/intl/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@
"breadcrumbs.more": "More items",
"toast.clearAll": "Clear all",
"toast.collapse": "Collapse",
"toast.showAll": "Show all"
"toast.showAll": "Show all",
"rangeCalendar.startTime": "Start time",
"rangeCalendar.endTime": "End time",
"calendar.time": "Time"
}
2 changes: 2 additions & 0 deletions packages/@react-spectrum/s2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@
"jest": "^29.5.0"
},
"dependencies": {
"@internationalized/date": "^3.8.2",
"@internationalized/number": "^3.6.3",
"@react-aria/calendar": "^3.8.3",
"@react-aria/collections": "3.0.0-rc.3",
"@react-aria/focus": "^3.20.5",
"@react-aria/i18n": "^3.12.10",
Expand Down
309 changes: 289 additions & 20 deletions packages/@react-spectrum/s2/src/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,307 @@
* governing permissions and limitations under the License.
*/

import {ActionButton, Header, Heading, pressScale} from './';
import {
Calendar as AriaCalendar,
CalendarCell as AriaCalendarCell,
CalendarProps as AriaCalendarProps,
Button,
CalendarCell,
ButtonProps,
CalendarCellProps,
CalendarCellRenderProps,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader,
CalendarHeaderCell,
CalendarStateContext,
ContextValue,
DateValue,
Heading,
Text
} from 'react-aria-components';
import {ReactNode} from 'react';
import {baseColor, focusRing, lightDark, style} from '../style' with {type: 'macro'};
import ChevronLeftIcon from '../s2wf-icons/S2_Icon_ChevronLeft_20_N.svg';
import ChevronRightIcon from '../s2wf-icons/S2_Icon_ChevronRight_20_N.svg';
import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
import {createContext, ForwardedRef, forwardRef, Fragment, ReactNode, useContext, useMemo, useRef} from 'react';
import {forwardRefType, ValidationResult} from '@react-types/shared';
import {getEraFormat} from '@react-aria/calendar';
import {useDateFormatter} from '@react-aria/i18n';
import {useSpectrumContextProps} from './useSpectrumContextProps';


export interface CalendarProps<T extends DateValue>
extends AriaCalendarProps<T> {
errorMessage?: string
extends Omit<AriaCalendarProps<T>, 'visibleDuration' | 'style' | 'className' | 'styles'>,
StyleProps {
errorMessage?: ReactNode | ((v: ValidationResult) => ReactNode),
visibleMonths?: number
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc comments?

}

export function Calendar<T extends DateValue>(
{errorMessage, ...props}: CalendarProps<T>
): ReactNode {
export const CalendarContext = createContext<ContextValue<Partial<CalendarProps<any>>, HTMLDivElement>>(null);

const calendarStyles = style({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't shared styles between any of the components yet, waiting for development to settle a little bit more and anticipating the style macro issue with module boundaries we have right now

display: 'flex',
flexDirection: 'column',
gap: 24,
width: 'fit'
}, getAllowedOverrides());

const headerStyles = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: 'full'
});

const headingStyles = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
margin: 0,
width: 'full'
});

const titleStyles = style({
font: 'title-lg',
textAlign: 'center',
flexGrow: 1,
flexShrink: 0,
flexBasis: '0%',
minWidth: 0
});

const headerCellStyles = style({
font: 'title-sm',
cursor: 'default',
textAlign: 'center',
flexGrow: 1
});

const cellStyles = style<CalendarCellRenderProps>({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the padding on the td itself? Can you use margin on the cell button instead? Then we don't have to add extra class name props

paddingX: 4,
'--cell-gap': {
type: 'paddingStart',
value: 4
},
paddingY: 2
});

const cellInnerStyles = style({
...focusRing(),
outlineOffset: {
default: -2,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda weird that the cells look like they are smaller when not selected... wonder if that is intentional.

isToday: 2,
isSelected: 2
},
position: 'relative',
font: 'body-sm',
cursor: 'default',
width: 32,
height: 32,
margin: 2,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the padding on the parent, this ends up with 12px of space between the cells instead of 8

borderRadius: 'full',
display: {
default: 'flex',
isOutsideMonth: 'none'
},
alignItems: 'center',
justifyContent: 'center',
backgroundColor: {
default: 'transparent',
isHovered: 'gray-100',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's unavailable there shouldn't be a hover state

isToday: {
default: baseColor('gray-300'),
isDisabled: 'disabled'
},
isSelected: {
default: lightDark('accent-900', 'accent-700'),
isHovered: lightDark('accent-1000', 'accent-600'),
isPressed: lightDark('accent-1000', 'accent-600'),
isFocusVisible: lightDark('accent-1000', 'accent-600')
}
},
color: {
isSelected: 'white',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be a color defined in other states too?

isDisabled: 'disabled'
}
});

const unavailableStyles = style({
position: 'absolute',
top: 'calc(50% - 1px)',
left: 'calc(25% - 1px)',
right: 'calc(25% - 1px)',
height: 2,
transform: 'rotate(-16deg)',
borderRadius: 'full',
backgroundColor: '[currentColor]'
});

export const helpTextStyles = style({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any way we can re-use the existing help text component instead of duplicating it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsure how we want to proceed with this one, HelpText has a validation state context that it uses, but Calendar doesn't have that, so i'd need to introduce form validation hooks/etc

gridArea: 'helptext',
display: 'flex',
alignItems: 'baseline',
gap: 'text-to-visual',
font: controlFont(),
color: {
default: 'neutral-subdued',
isInvalid: 'negative',
isDisabled: 'disabled'
},
'--iconPrimary': {
type: 'fill',
value: 'currentColor'
},
contain: 'inline-size',
paddingTop: '--field-gap',
cursor: {
default: 'text',
isDisabled: 'default'
}
});


export const Calendar = /*#__PURE__*/ (forwardRef as forwardRefType)(function Calendar<T extends DateValue>(props: CalendarProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useSpectrumContextProps(props, ref, CalendarContext);
let {
visibleMonths = 1,
errorMessage,
UNSAFE_style,
UNSAFE_className,
styles,
...otherProps
} = props;
return (
<AriaCalendar {...props}>
<header>
<Button slot="previous">◀</Button>
<Heading />
<Button slot="next">▶</Button>
</header>
<CalendarGrid>
{(date) => <CalendarCell date={date} />}
</CalendarGrid>
{errorMessage && <Text slot="errorMessage">{errorMessage}</Text>}
<AriaCalendar
{...otherProps}
ref={ref}
visibleDuration={{months: visibleMonths}}
style={UNSAFE_style}
className={(UNSAFE_className || '') + calendarStyles(null, styles)}>
{({isInvalid, isDisabled}) => {
return (
<>
<Header styles={headerStyles}>
<CalendarButton slot="previous"><ChevronLeftIcon /></CalendarButton>
<CalendarHeading />
<CalendarButton slot="next"><ChevronRightIcon /></CalendarButton>
</Header>
<div
className={style({
display: 'flex',
flexDirection: 'row',
gap: 24,
width: 'full'
})}>
{Array.from({length: visibleMonths}).map((_, i) => (
<CalendarGrid offset={{months: i}} key={i}>
<CalendarGridHeader>
{(day) => (
<CalendarHeaderCell className={headerCellStyles}>
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody>
{(date) => (
<CalendarCell date={date} />
)}
</CalendarGridBody>
</CalendarGrid>
))}
</div>
{errorMessage && (
<Text slot="errorMessage" className={helpTextStyles({isInvalid, isDisabled})}>
{/* @ts-ignore */}
{errorMessage}
</Text>
)}
</>
);
}}
</AriaCalendar>
);
}
});

// Ordinarily the heading is a formatted date range, ie January 2025 - February 2025.
// However, we want to show each month individually.
const CalendarHeading = () => {
let {visibleRange, timeZone} = useContext(CalendarStateContext) ?? {};
let era: any = getEraFormat(visibleRange?.start) || getEraFormat(visibleRange?.end);
let monthFormatter = useDateFormatter({
month: 'long',
year: 'numeric',
era,
calendar: visibleRange?.start.calendar.identifier,
timeZone
});
let months = useMemo(() => {
if (!visibleRange) {
return [];
}
let months: string[] = [];
for (let i = visibleRange.start; i.compare(visibleRange.end) <= 0; i = i.add({months: 1})) {
// TODO: account for the first week possibly overlapping, like with a custom 454 calendar.
// there has to be a better way to do this...
if (i.month === visibleRange.start.month) {
i = i.add({weeks: 1});
}
months.push(monthFormatter.format(i.toDate(timeZone!)));
}
return months;
}, [visibleRange, monthFormatter, timeZone]);

return (
<Heading styles={headingStyles}>
{months.map((month, i) => {
if (i === 0) {
return (
<Fragment key={month}>
<div className={titleStyles}>{month}</div>
</Fragment>
);
} else {
return (
<Fragment key={month}>
{/* Spacers to account for Next/Previous buttons and gap, spelled out to show the math */}
<div className={style({visibility: 'hidden', width: 32})} role="presentation" />
<div className={style({visibility: 'hidden', width: 24})} role="presentation" />
<div className={style({visibility: 'hidden', width: 32})} role="presentation" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't need presentation I think, visibility: hidden already removes it from the a11y tree

<div className={titleStyles}>{month}</div>
</Fragment>
);
}
})}
</Heading>
);
};

const CalendarButton = (props: Omit<ButtonProps, 'children'> & {children: ReactNode}) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the designs the buttons somehow align with the first day of the week. Seems like they need some margin or something?

return (
<ActionButton
{...props}
isQuiet>
{props.children}
</ActionButton>
);
};

const CalendarCell = (props: Omit<CalendarCellProps, 'children'>) => {
let ref = useRef<HTMLTableCellElement>(null);
return (
<AriaCalendarCell
ref={ref}
date={props.date}
cellClassName={cellStyles}
style={pressScale(ref, {})}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add the default transition if we're keeping the press scale

className={cellInnerStyles}>
{({isUnavailable, formattedDate}) => (
<>
<div>
{formattedDate}
</div>
{isUnavailable && <div className={unavailableStyles} role="presentation" />}
</>
)}
</AriaCalendarCell>
);
};
3 changes: 2 additions & 1 deletion packages/@react-spectrum/s2/src/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ interface ContentProps extends UnsafeStyles, SlotProps {
id?: string
}

interface HeadingProps extends ContentProps {
interface HeadingProps extends Omit<ContentProps, 'children'> {
children?: ReactNode,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed to replace children in RangeCalendar

level?: number
}

Expand Down
Loading