-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
base: main
Are you sure you want to change the base?
Changes from all commits
c143634
3b4f669
6401de3
7bbdd47
0716d72
b67342f
beb5265
fb693ab
1ab4699
f51db9c
b1d5752
c8e2ef2
eb5c4ff
aa71a83
8b864c3
0cbd107
559c1bc
0f697a8
90332c7
6fbf922
a82e047
3db69f6
356148a
b0f6cac
eb9685a
6da6c82
5ee804b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, {})} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,7 +27,8 @@ interface ContentProps extends UnsafeStyles, SlotProps { | |
id?: string | ||
} | ||
|
||
interface HeadingProps extends ContentProps { | ||
interface HeadingProps extends Omit<ContentProps, 'children'> { | ||
children?: ReactNode, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needed to replace children in RangeCalendar |
||
level?: number | ||
} | ||
|
||
|
There was a problem hiding this comment.
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