-
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 27 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
f065877
d17b7fd
be9560f
3450170
aa7701d
27c4230
33628a0
fad7550
6ee3dc6
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 | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
export function Calendar<T extends DateValue>( | ||
{errorMessage, ...props}: CalendarProps<T> | ||
): ReactNode { | ||
export const CalendarContext = createContext<ContextValue<Partial<CalendarProps<any>>, HTMLDivElement>>(null); | ||
|
||
const calendarStyles = style({ | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 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. Found a different way I like better, using table attributes to get rid of the td default padding |
||
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. 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. Yep, see discussion with design |
||
isToday: 2, | ||
isSelected: 2 | ||
}, | ||
position: 'relative', | ||
font: 'body-sm', | ||
cursor: 'default', | ||
width: 32, | ||
height: 32, | ||
margin: 2, | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
borderRadius: 'full', | ||
display: { | ||
default: 'flex', | ||
isOutsideMonth: 'none' | ||
}, | ||
alignItems: 'center', | ||
justifyContent: 'center', | ||
backgroundColor: { | ||
default: 'transparent', | ||
isHovered: 'gray-100', | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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', | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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" /> | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
<div className={titleStyles}>{month}</div> | ||
</Fragment> | ||
); | ||
} | ||
})} | ||
</Heading> | ||
); | ||
}; | ||
|
||
const CalendarButton = (props: Omit<ButtonProps, 'children'> & {children: ReactNode}) => { | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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, {})} | ||
snowystinger marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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