Skip to content

Commit 788fcb8

Browse files
authored
feat: Add DatePicker component (#511)
1 parent 92876bf commit 788fcb8

File tree

14 files changed

+912
-53
lines changed

14 files changed

+912
-53
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@
3333
"@loomhq/loom-embed": "1.5.0",
3434
"@markdoc/markdoc": "0.3.0",
3535
"@monaco-editor/react": "4.5.1",
36-
"@react-aria/utils": "3.17.0",
36+
"@react-aria/utils": "3.19.0",
3737
"@react-hooks-library/core": "0.5.1",
38-
"@react-stately/utils": "3.6.0",
39-
"@react-types/shared": "3.18.1",
38+
"@react-stately/utils": "3.7.0",
39+
"@react-types/shared": "3.19.0",
4040
"@tanstack/match-sorter-utils": "8.8.4",
4141
"@tanstack/react-table": "8.9.1",
4242
"@tanstack/react-virtual": "3.0.0-beta.54",

src/components/Calendar.tsx

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import {
2+
type AriaButtonProps,
3+
type AriaCalendarGridProps,
4+
type AriaCalendarProps,
5+
useButton,
6+
useCalendar,
7+
useCalendarCell,
8+
useCalendarGrid,
9+
useLocale,
10+
} from 'react-aria'
11+
import { type CalendarState, useCalendarState } from 'react-stately'
12+
import { createCalendar, getWeeksInMonth } from '@internationalized/date'
13+
14+
// Reuse the Button from your component library. See below for details.
15+
16+
import styled from 'styled-components'
17+
import React, { type ComponentProps, type ReactNode, useRef } from 'react'
18+
import { type Merge } from 'type-fest'
19+
20+
import classNames from 'classnames'
21+
22+
import { ArrowLeftIcon, ArrowRightIcon } from '../icons'
23+
24+
import IconFrame from './IconFrame'
25+
26+
const CalendarCellSC = styled.td(({ theme }) => ({
27+
...theme.partials.text.body2,
28+
color: theme.colors.text,
29+
display: 'flex',
30+
alignItems: 'center',
31+
justifyContent: 'center',
32+
padding: 0,
33+
'.cellButton': {
34+
// background: 'blue',
35+
width: theme.spacing.xlarge,
36+
height: theme.spacing.xlarge,
37+
display: 'flex',
38+
alignItems: 'center',
39+
justifyContent: 'center',
40+
textAlign: 'right',
41+
cursor: 'pointer',
42+
borderRadius: theme.borderRadiuses.medium,
43+
'&:hover': {
44+
backgroundColor: theme.colors['fill-two-hover'],
45+
},
46+
'&:focus': {
47+
outline: 'none',
48+
},
49+
'&:focus-visible': {
50+
...theme.partials.focus.button,
51+
},
52+
'&.disabled, &.unavailable': {
53+
cursor: 'not-allowed',
54+
color: theme.colors['text-input-disabled'],
55+
},
56+
'&.selected': {
57+
backgroundColor: theme.colors['fill-two-selected'],
58+
},
59+
'&.outsideRange': {
60+
display: 'none',
61+
},
62+
},
63+
}))
64+
65+
function CalendarCell({ state, date }: any) {
66+
const ref = React.useRef(null)
67+
const {
68+
cellProps,
69+
buttonProps,
70+
isSelected,
71+
isOutsideVisibleRange,
72+
isDisabled,
73+
isUnavailable,
74+
formattedDate,
75+
} = useCalendarCell({ date }, state, ref)
76+
77+
return (
78+
<CalendarCellSC {...cellProps}>
79+
<div
80+
{...buttonProps}
81+
ref={ref}
82+
hidden={isOutsideVisibleRange}
83+
className={classNames('cellButton', {
84+
selected: isSelected,
85+
disabled: isDisabled,
86+
unavailable: isUnavailable,
87+
outsideRange: isOutsideVisibleRange,
88+
})}
89+
>
90+
{formattedDate}
91+
</div>
92+
</CalendarCellSC>
93+
)
94+
}
95+
96+
const CalendarGridSC = styled.table(({ theme }) => ({
97+
display: 'grid',
98+
gridTemplateColumns: `repeat(7, minmax(0, 1fr))`,
99+
gap: theme.spacing.xsmall,
100+
101+
'tr, tbody, thead': {
102+
display: 'contents',
103+
},
104+
thead: {
105+
...theme.partials.text.body2Bold,
106+
},
107+
th: {
108+
padding: 0,
109+
paddingBottom: theme.spacing.xxsmall,
110+
},
111+
}))
112+
113+
function CalendarGrid({
114+
state,
115+
...props
116+
}: Merge<
117+
Merge<ComponentProps<typeof CalendarGridSC>, AriaCalendarGridProps>,
118+
{
119+
state: CalendarState
120+
}
121+
>) {
122+
const { locale } = useLocale()
123+
const {
124+
gridProps: gridEltProps,
125+
headerProps,
126+
weekDays,
127+
} = useCalendarGrid(props, state)
128+
129+
// Get the number of weeks in the month so we can render the proper number of rows.
130+
const weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale)
131+
132+
return (
133+
<CalendarGridSC {...gridEltProps}>
134+
<thead {...headerProps}>
135+
<tr>
136+
{weekDays.map((day, index) => (
137+
<th key={index}>{day}</th>
138+
))}
139+
</tr>
140+
</thead>
141+
<tbody>
142+
{[...new Array(weeksInMonth).keys()].map((weekIndex) => (
143+
<tr key={weekIndex}>
144+
{state.getDatesInWeek(weekIndex).map((date, i) =>
145+
date ? (
146+
<CalendarCell
147+
key={i}
148+
state={state}
149+
date={date}
150+
/>
151+
) : (
152+
<td key={i} />
153+
)
154+
)}
155+
</tr>
156+
))}
157+
</tbody>
158+
</CalendarGridSC>
159+
)
160+
}
161+
162+
const CalendarSC = styled.div(({ theme }) => ({
163+
flexGrow: 0,
164+
display: 'flex',
165+
flexDirection: 'column',
166+
rowGap: theme.spacing.medium,
167+
'.header': {
168+
display: 'flex',
169+
alignItems: 'center',
170+
'.title': {
171+
margin: 0,
172+
order: 2,
173+
...theme.partials.text.subtitle2,
174+
textAlign: 'center',
175+
flexGrow: 1,
176+
padding: `0 ${theme.spacing.small}px`,
177+
},
178+
'.button': {
179+
width: `${100 / 7}%`,
180+
// flexShrink: 1,
181+
display: 'flex',
182+
justifyContent: 'center',
183+
'> *': {
184+
flexShrink: 0,
185+
flexGrow: 0,
186+
},
187+
},
188+
'.nextButton': {
189+
order: 3,
190+
},
191+
'.prevButton': {
192+
order: 1,
193+
},
194+
},
195+
}))
196+
197+
const NextPrevButtonSC = styled(IconFrame)<{ $disabled: boolean }>(
198+
({ $disabled, theme }) => ({
199+
'&&': {
200+
border: theme.borders.input,
201+
...($disabled
202+
? { cursor: 'not-allowed', color: theme.colors['icon-disabled'] }
203+
: {}),
204+
},
205+
})
206+
)
207+
208+
function NextPrevButton({
209+
icon,
210+
...props
211+
}: AriaButtonProps & { icon: ReactNode }) {
212+
const ref = useRef(null)
213+
const { buttonProps: useButtonProps } = useButton(props, ref)
214+
215+
return (
216+
<NextPrevButtonSC
217+
ref={ref}
218+
{...(useButtonProps as any)}
219+
icon={icon}
220+
clickable={!useButtonProps.disabled}
221+
$disabled={useButtonProps.disabled}
222+
type="tertiary"
223+
size="medium"
224+
/>
225+
)
226+
}
227+
228+
export function Calendar({
229+
...props
230+
}: Merge<ComponentProps<typeof CalendarSC>, AriaCalendarProps<any>>) {
231+
const { locale } = useLocale()
232+
const state = useCalendarState({
233+
...props,
234+
locale,
235+
createCalendar,
236+
})
237+
238+
const {
239+
calendarProps: calendarEltProps,
240+
prevButtonProps,
241+
nextButtonProps,
242+
title,
243+
} = useCalendar(props, state)
244+
245+
return (
246+
<CalendarSC
247+
{...calendarEltProps}
248+
className="calendar"
249+
>
250+
<div className="header">
251+
<h3 className="title">{title}</h3>
252+
<div className="button prevButton">
253+
{/* @ts-ignore */}
254+
<NextPrevButton
255+
icon={<ArrowLeftIcon />}
256+
{...prevButtonProps}
257+
/>
258+
</div>
259+
<div className="button nextButton">
260+
{/* @ts-ignore */}
261+
<NextPrevButton
262+
icon={<ArrowRightIcon />}
263+
{...nextButtonProps}
264+
/>
265+
</div>
266+
</div>
267+
<CalendarGrid state={state} />
268+
</CalendarSC>
269+
)
270+
}

src/components/DateField.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
type AriaDateFieldProps,
3+
useDateField,
4+
useDateSegment,
5+
useLocale,
6+
} from 'react-aria'
7+
import { type DateFieldState, useDateFieldState } from 'react-stately'
8+
import { createCalendar } from '@internationalized/date'
9+
import { type ComponentProps, useRef } from 'react'
10+
import styled from 'styled-components'
11+
import { type Merge } from 'type-fest'
12+
import classNames from 'classnames'
13+
14+
const DateSegmentSC = styled.div(({ theme }) => {
15+
const xPad = 1
16+
const vPad = 2
17+
18+
return {
19+
...theme.partials.text.body1,
20+
display: 'block',
21+
'&:focus, &:focus-visible': {
22+
outline: 'none',
23+
},
24+
padding: `${vPad}px ${xPad}px`,
25+
margin: `${-vPad * 2}px 0`,
26+
'&.segment-literal': {
27+
padding: '0 1px',
28+
margin: '0',
29+
},
30+
'&.first': {
31+
marginLeft: -2,
32+
},
33+
'&.last': {
34+
marginRight: -2,
35+
},
36+
37+
textTransform: 'uppercase',
38+
':focus-visible': {
39+
background: theme.colors['fill-three-selected'],
40+
borderRadius: theme.borderRadiuses.medium,
41+
},
42+
}
43+
})
44+
45+
export function DateSegment({
46+
segment,
47+
state,
48+
first,
49+
last,
50+
}: {
51+
segment: Parameters<typeof useDateSegment>[0]
52+
state: DateFieldState
53+
first: boolean
54+
last: boolean
55+
}) {
56+
const ref = useRef(null)
57+
const { segmentProps } = useDateSegment(segment, state, ref)
58+
59+
return (
60+
<DateSegmentSC
61+
{...segmentProps}
62+
ref={ref}
63+
className={classNames('segment', `segment-${segment.type}`, {
64+
placeholder: segment.isPlaceholder,
65+
first,
66+
last,
67+
})}
68+
>
69+
{segment.text}
70+
</DateSegmentSC>
71+
)
72+
}
73+
74+
export const DateFieldWrapperSC = styled.div(({ theme: _ }) => ({
75+
display: 'flex',
76+
alignItems: 'center',
77+
}))
78+
79+
export function DateField({
80+
...props
81+
}: Merge<ComponentProps<typeof DateFieldWrapperSC>, AriaDateFieldProps<any>>) {
82+
const { locale } = useLocale()
83+
const state = useDateFieldState({
84+
...props,
85+
locale,
86+
createCalendar,
87+
})
88+
89+
const ref = useRef<HTMLDivElement>(null)
90+
const { fieldProps } = useDateField(props, state, ref)
91+
92+
return (
93+
<DateFieldWrapperSC
94+
{...fieldProps}
95+
ref={ref}
96+
className="field dateField"
97+
>
98+
{state.segments.map((segment, i, segments) => (
99+
<DateSegment
100+
key={i}
101+
first={i === 0}
102+
last={i === segments.length - 1}
103+
segment={segment}
104+
state={state}
105+
/>
106+
))}
107+
</DateFieldWrapperSC>
108+
)
109+
}

0 commit comments

Comments
 (0)